diff --git a/.github/workflows/bastion.yml b/.github/workflows/bastion.yml new file mode 100644 index 00000000000..5ac94dc5ec2 --- /dev/null +++ b/.github/workflows/bastion.yml @@ -0,0 +1,711 @@ +name: Bastion (full-stack sample) + +# scripts/bastion is one Maven project holding a banking application and its +# server: a `shared` module of transfer objects and @RestClient contracts, the +# Codename One client that calls them, and a Codename One Backend that +# implements them. +# +# Five things are checked here, because each fails without the others noticing: +# +# backend the server's own tests, on the JVM and compiled by ParparVM, +# on SQLite and again on PostgreSQL and MySQL (backend-database) +# client-e2e the app's own tests, and the server driven over HTTP the way the +# app drives it (run-e2e.sh): sign-in with a second factor, +# device registration, a signed payment, an order that fills and +# a withdrawal the allow-list refuses -- on the JVM server and +# the compiled one +# web the browser build of the application, hosted by the server +# (cn1:backend-webapp) and opened in a headless browser +# android the Android Gradle project builds +# ios the Xcode project generates and builds for the simulator +# +# The project has the minimal layout, with no platform modules: common builds +# the simulator, the desktop, Android, iOS and the browser, and +# build-android-app.sh and build-ios-app.sh find the device projects there. +# +# What only a cloud build turns on -- the hardening passes, Play Integrity and +# the jailbreak checks -- is not built here; the app reports it as inactive. + +on: + pull_request: + paths: + - 'scripts/bastion/**' + - 'vm/backend/src/**' + - 'vm/backend/impl/**' + - 'maven/backend/**' + - 'maven/backend-test/**' + - 'maven/build-engine/src/main/java/com/codename1/maven/**' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' + - 'CodenameOne/src/com/codename1/io/oidc/**' + - 'CodenameOne/src/com/codename1/io/rest/**' + - 'CodenameOne/src/com/codename1/mapping/**' + # The device key, request signing, the vault, the shield and the second + # factor are all the core's. + - 'CodenameOne/src/com/codename1/security/**' + # P-256 runs on the core's big numbers. + - 'CodenameOne/src/com/codename1/util/BigInteger.java' + - 'CodenameOne/src/com/codename1/util/BigDecimal.java' + - 'CodenameOne/src/com/codename1/util/TBigInteger.java' + - 'CodenameOne/src/com/codename1/util/TBigDecimal.java' + - 'CodenameOne/src/com/codename1/util/TBitLevel.java' + - 'CodenameOne/src/com/codename1/util/TDivision.java' + # The ORM validates every mapping against its migration when the + # server starts, and the backend-database job is where that runs + # against a real database. + - 'CodenameOne/src/com/codename1/impl/orm/**' + - 'CodenameOne/src/com/codename1/orm/**' + - 'CodenameOne/src/com/codename1/annotations/**' + - 'scripts/ci/sample-server-tests-on-database.sh' + - 'Ports/JavaScriptPort/src/main/**' + - 'scripts/build-android-app.sh' + - 'scripts/build-ios-app.sh' + - 'scripts/sync-initializr-template.py' + - 'scripts/tests/generate-initializr-fixtures.py' + - 'scripts/tests/GenerateInitializr.java' + # The generator and the template. + - 'scripts/initializr/common/src/**' + # Everything else this sample needs from the framework and the ports: + # the platform crypto and secure storage it signs and seals with, the + # browser bridge its passkeys go through, and the builders of the + # device projects. + - 'CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java' + - 'CodenameOne/src/com/codename1/io/Util.java' + # Copied account numbers are sensitive clips that clear themselves. + - 'CodenameOne/src/com/codename1/ui/ClipboardContent.java' + - 'CodenameOne/src/com/codename1/ui/Display.java' + - 'Ports/Android/**' + - 'Ports/iOSPort/**' + - 'Ports/JavaSE/src/**' + - 'vm/ByteCodeTranslator/src/javascript/browser_bridge.js' + - 'maven/build-engine/src/main/java/com/codename1/builders/**' + - '.github/workflows/bastion.yml' + # The same paths as pull_request: a change that a PR run would have + # checked is checked again when it lands. + push: + branches: [ master ] + paths: + - 'scripts/bastion/**' + - 'vm/backend/src/**' + - 'vm/backend/impl/**' + - 'maven/backend/**' + - 'maven/backend-test/**' + - 'maven/build-engine/src/main/java/com/codename1/maven/**' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' + - 'CodenameOne/src/com/codename1/io/oidc/**' + - 'CodenameOne/src/com/codename1/io/rest/**' + - 'CodenameOne/src/com/codename1/mapping/**' + # The device key, request signing, the vault, the shield and the second + # factor are all the core's. + - 'CodenameOne/src/com/codename1/security/**' + # P-256 runs on the core's big numbers. + - 'CodenameOne/src/com/codename1/util/BigInteger.java' + - 'CodenameOne/src/com/codename1/util/BigDecimal.java' + - 'CodenameOne/src/com/codename1/util/TBigInteger.java' + - 'CodenameOne/src/com/codename1/util/TBigDecimal.java' + - 'CodenameOne/src/com/codename1/util/TBitLevel.java' + - 'CodenameOne/src/com/codename1/util/TDivision.java' + # The ORM validates every mapping against its migration when the + # server starts, and the backend-database job is where that runs + # against a real database. + - 'CodenameOne/src/com/codename1/impl/orm/**' + - 'CodenameOne/src/com/codename1/orm/**' + - 'CodenameOne/src/com/codename1/annotations/**' + - 'scripts/ci/sample-server-tests-on-database.sh' + - 'Ports/JavaScriptPort/src/main/**' + - 'scripts/build-android-app.sh' + - 'scripts/build-ios-app.sh' + - 'scripts/sync-initializr-template.py' + - 'scripts/tests/generate-initializr-fixtures.py' + - 'scripts/tests/GenerateInitializr.java' + # The generator and the template. + - 'scripts/initializr/common/src/**' + # Everything else this sample needs from the framework and the ports: + # the platform crypto and secure storage it signs and seals with, the + # browser bridge its passkeys go through, and the builders of the + # device projects. + - 'CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java' + - 'CodenameOne/src/com/codename1/io/Util.java' + # Copied account numbers are sensitive clips that clear themselves. + - 'CodenameOne/src/com/codename1/ui/ClipboardContent.java' + - 'CodenameOne/src/com/codename1/ui/Display.java' + - 'Ports/Android/**' + - 'Ports/iOSPort/**' + - 'Ports/JavaSE/src/**' + - 'vm/ByteCodeTranslator/src/javascript/browser_bridge.js' + - 'maven/build-engine/src/main/java/com/codename1/builders/**' + - '.github/workflows/bastion.yml' + workflow_dispatch: + +concurrency: + # Only the latest revision needs validation. PR numbers keep forks isolated; + # event names keep a manual run from cancelling a push or PR run. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + # Required to pull the pr-ci-container image from ghcr.io. + packages: read + +jobs: + template-sync: + name: Initializr template matches the sample + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + # The template's resources are derived from scripts/bastion and committed. + # A change to one without the other is a template that is not the project + # this workflow tests. + - name: Compare the template with scripts/bastion + run: python3 scripts/sync-initializr-template.py bastion + + backend: + name: Server tests (JVM and compiled) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + + - name: Install native build tools + run: | + bash scripts/ci/apt-get-update.sh + # A server binary links libcurl, OpenSSL and nghttp2; clang alone + # compiles the tests and then fails at the link. + sudo apt-get install -y clang zlib1g-dev libcurl4-openssl-dev libssl-dev libnghttp2-dev + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + # The quality gates run in pr.yml; here they would only slow an install + # whose purpose is to put this checkout's artifacts in the repository. + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the server tests on the JVM and compiled + working-directory: scripts/bastion + run: | + mvn -B -Dcodename1.platform=backend -pl shared,backend test \ + -Dcn1.backend.compiledTests=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + # A goal that skipped leaves the job green, so the reports are what prove + # that both runners ran. + - name: Check that both runners reported + working-directory: scripts/bastion/backend/target/surefire-reports + run: | + jvm=$(find . -maxdepth 1 -name 'TEST-*.xml' ! -name '*-compiled.xml' | wc -l) + compiled=$(find . -maxdepth 1 -name 'TEST-*-compiled.xml' | wc -l) + echo "JVM reports: $jvm, compiled reports: $compiled" + if [ "$jvm" -eq 0 ] || [ "$compiled" -eq 0 ]; then + echo "::error::a runner produced no test reports" + exit 1 + fi + + - name: Upload the test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: bastion-backend-tests + path: scripts/bastion/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + chain: + name: Ether on a development chain + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Deposit, sweep and withdraw on a geth development node + run: scripts/bastion/run-chain-test.sh + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + MAVEN_OPTS: -Dmaven.repo.local=${{ github.workspace }}/.m2-repo + + # The integration test skips itself without a node, which would leave + # this job green having proved nothing. + - name: Check that the chain test ran + working-directory: scripts/bastion/backend/target/surefire-reports + run: | + f=TEST-com.codenameone.examples.bastion.chain.ChainIntegrationTest.xml + grep -o 'tests="[0-9]*"[^>]*skipped="[0-9]*"' "$f" + if ! grep -q 'skipped="0"' "$f" || grep -q 'tests="0"' "$f"; then + echo "::error::ChainIntegrationTest did not run" + exit 1 + fi + + backend-database: + name: Server tests on ${{ matrix.database }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + # The backend job runs on in-memory SQLite, which accepts any declared + # column type for any value and has no collations to get wrong. PostgreSQL + # and MySQL are what a deployment runs on, and where the ORM's schema + # validation refuses a mapping that disagrees with its migration. + strategy: + fail-fast: false + matrix: + database: [postgresql, mysql] + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: cn1 + POSTGRES_PASSWORD: secret + POSTGRES_DB: cn1 + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U cn1" --health-interval 5s + --health-timeout 5s --health-retries 20 + steps: + - uses: actions/checkout@v6 + + # The runner's own MySQL 8.0 rather than a service container: a service + # cannot be given server options, and MySQL 8.4 (mysql:8) turns + # mysql_native_password off. The backend does caching_sha2_password's + # full exchange only over verified TLS, which a throwaway server has no + # certificate for. + - name: Start MySQL + if: matrix.database == 'mysql' + run: | + sudo systemctl start mysql.service + sudo mysql -uroot -proot -e "CREATE USER 'cn1'@'%' IDENTIFIED WITH mysql_native_password BY 'secret'; GRANT ALL ON *.* TO 'cn1'@'%'; CREATE DATABASE cn1;" + # The ledger reads what the writer before it committed, which + # REPEATABLE READ (MySQL's default) does not answer; the server + # refuses to start without this (IsolationCheck). + sudo mysql -uroot -proot -e "SET GLOBAL transaction_isolation = 'READ-COMMITTED';" + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the server tests on ${{ matrix.database }} + run: | + if [ "${{ matrix.database }}" = mysql ]; then + export CN1_TEST_DATABASE_URL="mysql://cn1:secret@127.0.0.1:3306/cn1?sslmode=disable" + else + export CN1_TEST_DATABASE_URL="postgres://cn1:secret@localhost:5432/cn1?sslmode=disable" + fi + scripts/ci/sample-server-tests-on-database.sh scripts/bastion \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + + - name: Upload the test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: bastion-backend-${{ matrix.database }}-tests + path: scripts/bastion/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + client-e2e: + name: The app's tests, and the server driven over HTTP (JVM and compiled) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + + - name: Install native build tools + run: | + bash scripts/ci/apt-get-update.sh + sudo apt-get install -y clang zlib1g-dev libcurl4-openssl-dev libssl-dev libnghttp2-dev + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Install Codename One artifacts (core / javase / css-compiler / plugin / backend) + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B \ + -pl core,javase,css-compiler,codenameone-maven-plugin,backend,backend-test \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + # Ahead of the app's own build: a template that does not build should + # not wait behind the end-to-end runs to say so. The Initializr's own + # tests, which cover this template too, run in wayline.yml. + - name: Build a project generated from the Initializr template + run: scripts/bastion/check-template.sh "$RUNNER_TEMP/bastion-template" + env: + JDK_8_HOME: ${{ env.JDK_8_HOME }} + JAVA_HOME: ${{ env.JDK_17_HOME }} + + # The app's own tests run headless in the build: the request signer + # against the JDK, the network guard, the declared build hints against + # the settings, the bundle, the QR encoder. + - name: Build the app and run its tests + working-directory: scripts/bastion + run: mvn -B -Dcodename1.platform=javase -Djava.awt.headless=true package + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + + - name: Drive the JVM server over HTTP + run: scripts/bastion/run-e2e.sh + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + - name: Drive the compiled server over HTTP + run: | + BASTION_SERVER_LOG="$GITHUB_WORKSPACE/scripts/bastion/target/e2e-server-native.log" \ + scripts/bastion/run-e2e.sh --native + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + - name: Upload the server logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: bastion-e2e + path: scripts/bastion/target/e2e-server*.log + if-no-files-found: warn + retention-days: 14 + + web: + name: Web app hosted by the server (headless browser) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up Node 20 + uses: actions/setup-node@v6 + with: + node-version: '20' + + - name: Cache Playwright browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('scripts/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-playwright-chromium- + + - name: Install Playwright Chromium + run: | + cd scripts + npm ci + # Playwright shells out to apt for the OS-level packages, so the + # runner image's third-party sources are pruned first. + bash "$GITHUB_WORKSPACE/scripts/ci/apt-get-update.sh" + npx playwright install-deps chromium + npx playwright install chromium + + # The JavaScript build is the plugin's own: the port and the translator + # travel inside it, so installing the plugin with what it is built from + # is all the browser build needs. + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B \ + -pl core,javase,css-compiler,codenameone-maven-plugin,backend,backend-test \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + # Builds the server and the browser build of the app, starts the server + # with the app staged where it serves it from, and signs in through the + # real screens. + - name: Run the hosted web app in a headless browser + run: scripts/bastion/run-web-e2e.sh + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + - name: Upload the screenshots and the logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: bastion-web-e2e + path: scripts/bastion/target/web-e2e/** + if-no-files-found: warn + retention-days: 14 + + android: + name: Android build + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CN1_APP_DIR: scripts/bastion + steps: + - uses: actions/checkout@v6 + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-bastion-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2-bastion- + ${{ runner.os }}-m2- + - name: Cache Gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-bastion-${{ hashFiles('Ports/Android/build.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle-bastion- + ${{ runner.os }}-gradle- + - name: Setup workspace + run: ./scripts/setup-workspace.sh -q -DskipTests + - name: Build Android port + run: ./scripts/build-android-port.sh -q -DskipTests + - name: Build the Android application + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + ./scripts/build-android-app.sh -q -DskipTests + + build-ios-port: + # The reusable workflow scripts-ios.yml depends on; sharing it shares its cache. + uses: ./.github/workflows/_build-ios-port.yml + + ios: + name: iOS build (simulator) + needs: build-ios-port + runs-on: macos-15 + timeout-minutes: 50 + env: + CN1_APP_DIR: scripts/bastion + steps: + - uses: actions/checkout@v6 + + - name: Cache CocoaPods and user gems + uses: actions/cache@v5 + with: + path: | + ~/.gem + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-pods-v1-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-pods-v1- + + - name: Ensure CocoaPods tooling + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + set -euo pipefail + GEM_USER_DIR="$(ruby -e 'print Gem.user_dir')" + export PATH="$GEM_USER_DIR/bin:$PATH" + if ! command -v pod >/dev/null 2>&1; then + gem install cocoapods xcodeproj --no-document --user-install + fi + pod --version + + - name: Compute setup-workspace hash + id: setup_hash + run: echo "hash=$(shasum -a 256 scripts/setup-workspace.sh | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Restore cn1-binaries cache + uses: actions/cache@v5 + with: + path: ../cn1-binaries + key: cn1-binaries-${{ runner.os }}-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + cn1-binaries-${{ runner.os }}- + + - name: Download built CN1 + iOS port bundle + uses: actions/download-artifact@v4 + with: + name: cn1-built-bundle + path: ${{ runner.temp }} + - name: Restore built CN1 + iOS port tree + run: | + set -euo pipefail + tar -C / -xf "$RUNNER_TEMP/cn1-built-bundle.tar" + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate the Xcode project + id: build-ios-app + run: ./scripts/build-ios-app.sh -q -DskipTests + timeout-minutes: 25 + + - name: Build the application for the simulator + run: | + set -euo pipefail + WORKSPACE='${{ steps.build-ios-app.outputs.workspace }}' + SCHEME='${{ steps.build-ios-app.outputs.scheme }}' + DERIVED_DATA="${{ runner.temp }}/bastion-dd" + rm -rf "$DERIVED_DATA" + # One architecture, the host's: the port's SIMD natives are NEON + # under __aarch64__, and a universal simulator build compiles an + # x86_64 slice that has no use here. + HOST_ARCH="$(uname -m)" + case "$HOST_ARCH" in + arm64|x86_64) BUILD_ARCH="$HOST_ARCH" ;; + *) BUILD_ARCH=arm64 ;; + esac + XCB_CONTAINER_FLAG="-workspace" + if [[ "$WORKSPACE" != *.xcworkspace ]]; then + XCB_CONTAINER_FLAG="-project" + fi + xcodebuild "$XCB_CONTAINER_FLAG" "$WORKSPACE" -scheme "$SCHEME" \ + -sdk iphonesimulator \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$DERIVED_DATA" \ + ARCHS="$BUILD_ARCH" ONLY_ACTIVE_ARCH=YES \ + EXCLUDED_ARCHS="armv7 armv7s" \ + CODE_SIGNING_ALLOWED=NO build + APP_BUNDLE="$(find "$DERIVED_DATA/Build/Products" -maxdepth 3 -type d -name "${SCHEME}.app" | head -n 1)" + if [ -z "$APP_BUNDLE" ]; then + echo "::error::no .app bundle under $DERIVED_DATA" + exit 1 + fi + timeout-minutes: 20 diff --git a/.github/workflows/check-doc-snippets.yml b/.github/workflows/check-doc-snippets.yml index 1461f1ba69f..4dea0bf5d2f 100644 --- a/.github/workflows/check-doc-snippets.yml +++ b/.github/workflows/check-doc-snippets.yml @@ -15,6 +15,7 @@ on: - 'docs/demos/**' - 'scripts/wayline/**' - 'scripts/savor/**' + - 'scripts/bastion/**' - 'scripts/skirmish/**' - 'docs/website/content/blog/**' - 'CodenameOne/src/**/*.java' diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index 62d4911a31e..bd13b6d4b1a 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -17,6 +17,8 @@ on: # The Savor and Parley chapters, the same way; savor.yml and parley.yml # compile and test the samples. - 'scripts/savor/**' + # The Bastion chapters, the same way; bastion.yml compiles and tests it. + - 'scripts/bastion/**' - 'scripts/skirmish/**' - 'scripts/parley/**' # The API-name check derives the published pages from the javadoc's own @@ -130,12 +132,13 @@ jobs: - 'Ports/JavaSE/**' - 'maven/core/**' - 'maven/javase/**' - # The Wayline, Savor and Parley chapters include these trees by tag, so a + # The Wayline, Savor, Parley and Bastion chapters include these trees by tag, so a # renamed or deleted tag there has to reach the snippet check. - 'scripts/wayline/**' - 'scripts/savor/**' - 'scripts/skirmish/**' - 'scripts/parley/**' + - 'scripts/bastion/**' workflow: - '.github/workflows/developer-guide-docs.yml' diff --git a/.github/workflows/initializr-e2e.yml b/.github/workflows/initializr-e2e.yml index 4b5165b7ece..a8d1845f684 100644 --- a/.github/workflows/initializr-e2e.yml +++ b/.github/workflows/initializr-e2e.yml @@ -23,6 +23,7 @@ on: - 'scripts/savor/common/src/test/**' - 'scripts/skirmish/common/src/test/**' - 'scripts/parley/common/src/test/**' + - 'scripts/bastion/common/src/test/**' schedule: - cron: '17 3 * * *' workflow_dispatch: @@ -42,7 +43,7 @@ jobs: strategy: fail-fast: false matrix: - template: [BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY] + template: [BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY, BASTION] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/parparvm-tests.yml b/.github/workflows/parparvm-tests.yml index a62373cb724..d612f1a3638 100644 --- a/.github/workflows/parparvm-tests.yml +++ b/.github/workflows/parparvm-tests.yml @@ -58,6 +58,7 @@ on: - 'CodenameOne/src/com/codename1/security/Hash.java' - 'CodenameOne/src/com/codename1/security/Hmac.java' - 'CodenameOne/src/com/codename1/security/MessageDigestImpl.java' + - 'CodenameOne/src/com/codename1/security/Secp256k1.java' - 'CodenameOne/src/com/codename1/security/Otp.java' - 'CodenameOne/src/com/codename1/security/Base32.java' - 'CodenameOne/src/com/codename1/security/CryptoException.java' @@ -122,6 +123,7 @@ on: - 'CodenameOne/src/com/codename1/security/Hash.java' - 'CodenameOne/src/com/codename1/security/Hmac.java' - 'CodenameOne/src/com/codename1/security/MessageDigestImpl.java' + - 'CodenameOne/src/com/codename1/security/Secp256k1.java' - 'CodenameOne/src/com/codename1/security/Otp.java' - 'CodenameOne/src/com/codename1/security/Base32.java' - 'CodenameOne/src/com/codename1/security/CryptoException.java' diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index 2f5096954b9..90bc6c9338a 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -64,6 +64,7 @@ on: # requests: developer-guide-docs.yml already renders the guide for those. - 'scripts/wayline/**' - 'scripts/savor/**' + - 'scripts/bastion/**' - 'scripts/skirmish/**' - 'scripts/parley/**' - 'scripts/website/**' diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index d546b46a245..025e5cf6ebd 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -6160,6 +6160,24 @@ public void copyToClipboard(ClipboardContent content) { copyToClipboard((Object) content); } + /// Takes a content copied with `ClipboardContent#setClearAfter(int)` off the clipboard once + /// its time is up. Called only when this application has copied nothing since. The default + /// replaces it with an empty clip, and only while the clipboard's text is still this + /// content's: another application may have copied over it meanwhile, and that copy is the + /// user's. A port that can identify its own clip checks that instead, and one whose platform + /// expires the content by itself does nothing. + /// + /// #### Parameters + /// + /// - `content`: the content that was copied + public void clearClipboardContent(ClipboardContent content) { + String copied = getPlainTextForClipboard(content); + if (copied == null || !copied.equals(getPlainTextForClipboard(getPasteDataFromClipboard()))) { + return; + } + copyToClipboard(new ClipboardContent()); + } + /// Returns the current content of the clipboard /// /// #### Returns diff --git a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java index f486b380167..fd5ae2c0474 100644 --- a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java +++ b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java @@ -108,6 +108,8 @@ public void run() { d.placeButtonCommands(new Command[]{cancel, open}); Command picked = d.showAtPosition(2, 2, 2, 2, true); String[] paths = tree.getSelectedPaths(); + // Identity on purpose: Command.equals compares names, and an accept label of + // "Cancel" would make the two buttons equal. if (picked == open && paths.length > 0) { //NOPMD CompareObjectsWithEquals deliver(new FileDialogResult(request, paths, -1, null), callback); } else { diff --git a/CodenameOne/src/com/codename1/security/EcKeys.java b/CodenameOne/src/com/codename1/security/EcKeys.java new file mode 100644 index 00000000000..14a9aa7f556 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/EcKeys.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +import com.codename1.impl.EcP256Codec; +import com.codename1.util.BigInteger; + +/// Completes a P-256 PKCS#8 private key with its public point. +/// +/// RFC 5915 makes the `publicKey` field of ECPrivateKey optional, and +/// java.security leaves it out -- so a key generated on a server or on the +/// JavaSE port has none. Apple's SecKey cannot import an EC private key +/// without the point, and cannot derive it, so [Signature#sign] hands the +/// port this completed form. The point is computed once per key (see +/// [PrivateKey]) with a fixed-length Montgomery ladder, see [#multiply]. +final class EcKeys { + private static final BigInteger P = new BigInteger( + "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff", 16); + private static final BigInteger GX = new BigInteger( + "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296", 16); + private static final BigInteger GY = new BigInteger( + "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5", 16); + + private EcKeys() {} + + /// Returns `pkcs8` itself when it already carries the point or is not a + /// P-256 key [EcP256Codec] understands, otherwise the same key re-encoded + /// with the point. + static byte[] withPublicPoint(byte[] pkcs8) { + byte[][] parts; + try { + parts = EcP256Codec.fromPkcs8(pkcs8); + } catch (RuntimeException notP256) { + return pkcs8; + } + if (parts[1] != null) { + return pkcs8; + } + BigInteger[] q = multiply(new BigInteger(1, parts[0])); + if (q == null) { + return pkcs8; + } + byte[] point = new byte[EcP256Codec.POINT_LENGTH]; + point[0] = 0x04; + putFixed(q[0], point, 1); + putFixed(q[1], point, 33); + return EcP256Codec.pkcs8FromScalar(parts[0], point); + } + + private static void putFixed(BigInteger v, byte[] out, int off) { + byte[] b = v.toByteArray(); + int start = b.length > 32 ? b.length - 32 : 0; + int len = b.length - start; + System.arraycopy(b, start, out, off + 32 - len, len); + } + + /// `k * G` in affine coordinates, or null for the point at infinity. + /// + /// A Montgomery ladder over all 256 bits of the scalar, on projective + /// points with the complete addition formulas of Renes, Costello and + /// Batina ("Complete addition formulas for prime order elliptic curves", + /// 2016, algorithm 4 for a = -3). Every bit costs the same: one addition + /// and one doubling, both through the one formula, which has no special + /// case for the identity or for equal points, and no field inversion until + /// the single one at the end. The two ladder points trade places by array + /// index, never by a branch on the bit. What the private scalar decides is + /// therefore not which operations run, nor how many, nor whether a short + /// key ends early. + /// + /// It is not constant time end to end: the big-integer arithmetic + /// underneath (`multiply`, `mod`) takes time that depends on its operands' + /// values and lengths, so the timing of a derivation still leaks something + /// about the scalar to an observer who can measure it closely and many + /// times. That is acceptable for its one use here -- deriving a stored + /// key's public point once, on the device -- and is why a port with a + /// native derivation should prefer it. + /// + /// A scalar outside `[1, n - 1]` is no private key: zero and `n` give the + /// point at infinity, and anything larger, or negative, would alias a + /// smaller key or fall outside the 256 bits the ladder walks. It answers + /// null, as the point at infinity does. + static BigInteger[] multiply(BigInteger k) { + if (k == null || k.signum() <= 0 || k.compareTo(N) >= 0) { + return null; + } + BigInteger[][] r = {{BigInteger.ZERO, BigInteger.ONE, BigInteger.ZERO}, + {GX, GY, BigInteger.ONE}}; + for (int i = 255; i >= 0; i--) { + int bit = k.testBit(i) ? 1 : 0; + // With bit = 1 the roles swap: r[1] is doubled and r[0] gets the sum. + BigInteger[] lo = r[bit]; + BigInteger[] hi = r[1 - bit]; + BigInteger[] sum = add(lo, hi); + BigInteger[] twice = add(lo, lo); + r[bit] = twice; + r[1 - bit] = sum; + } + BigInteger[] q = r[0]; + if (q[2].signum() == 0) { + return null; + } + // P is prime and 0 < z < P, so the inverse exists (BigIntegerModInverseTest + // holds modInverse to 256-bit operands). + BigInteger zInv = q[2].mod(P).modInverse(P); + return new BigInteger[]{q[0].multiply(zInv).mod(P), q[1].multiply(zInv).mod(P)}; + } + + /// The order of the base point. + private static final BigInteger N = new BigInteger( + "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551", 16); + + /// The curve's `b`. + private static final BigInteger B = new BigInteger( + "5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b", 16); + + private static BigInteger m(BigInteger a, BigInteger b) { + return a.multiply(b).mod(P); + } + + private static BigInteger p(BigInteger a, BigInteger b) { + return a.add(b).mod(P); + } + + private static BigInteger s(BigInteger a, BigInteger b) { + return a.subtract(b).mod(P); + } + + /// Complete projective addition, `u + v`, for a = -3 (RCB algorithm 4). + /// Valid for every pair of points, the identity (0:1:0) and `u == v` + /// included. + private static BigInteger[] add(BigInteger[] u, BigInteger[] v) { + BigInteger x1 = u[0]; + BigInteger y1 = u[1]; + BigInteger z1 = u[2]; + BigInteger x2 = v[0]; + BigInteger y2 = v[1]; + BigInteger z2 = v[2]; + BigInteger t0 = m(x1, x2); + BigInteger t1 = m(y1, y2); + BigInteger t2 = m(z1, z2); + BigInteger t3 = m(p(x1, y1), p(x2, y2)); + BigInteger t4 = p(t0, t1); + t3 = s(t3, t4); + t4 = m(p(y1, z1), p(y2, z2)); + BigInteger x3 = p(t1, t2); + t4 = s(t4, x3); + x3 = m(p(x1, z1), p(x2, z2)); + BigInteger y3 = p(t0, t2); + y3 = s(x3, y3); + BigInteger z3 = m(B, t2); + x3 = s(y3, z3); + z3 = p(x3, x3); + x3 = p(x3, z3); + z3 = s(t1, x3); + x3 = p(t1, x3); + y3 = m(B, y3); + t1 = p(t2, t2); + t2 = p(t1, t2); + y3 = s(y3, t2); + y3 = s(y3, t0); + t1 = p(y3, y3); + y3 = p(t1, y3); + t1 = p(t0, t0); + t0 = p(t1, t0); + t0 = s(t0, t2); + t1 = m(t4, y3); + t2 = m(t0, y3); + y3 = m(x3, z3); + y3 = p(y3, t2); + x3 = m(t3, x3); + x3 = s(x3, t1); + z3 = m(t4, z3); + t1 = m(t3, t0); + z3 = p(z3, t1); + return new BigInteger[]{x3, y3, z3}; + } +} diff --git a/CodenameOne/src/com/codename1/security/Hash.java b/CodenameOne/src/com/codename1/security/Hash.java index d98500a9b05..510c41fe5e4 100644 --- a/CodenameOne/src/com/codename1/security/Hash.java +++ b/CodenameOne/src/com/codename1/security/Hash.java @@ -31,6 +31,10 @@ /// - [#SHA256] -- 256 bit (SHA-2 family, recommended general-purpose hash) /// - [#SHA384] -- 384 bit (SHA-2 family) /// - [#SHA512] -- 512 bit (SHA-2 family) +/// - [#SHA3_256] -- 256 bit (SHA-3, FIPS 202) +/// - [#KECCAK256] -- 256 bit original Keccak, the hash Ethereum uses +/// for addresses, transaction hashes and ABI selectors. It is NOT SHA3-256: +/// the two differ in the padding byte and give different digests. /// /// #### Quick example /// @@ -74,6 +78,14 @@ public final class Hash { /// SHA-512 algorithm identifier (512-bit digest, SHA-2 family). public static final String SHA512 = "SHA-512"; + /// SHA3-256 algorithm identifier (256-bit digest, FIPS 202). + public static final String SHA3_256 = "SHA3-256"; + + /// Keccak-256 algorithm identifier (256-bit digest). This is the original + /// Keccak submission with the `0x01` padding byte, as used throughout + /// Ethereum; standardised SHA3-256 pads with `0x06` and is [#SHA3_256]. + public static final String KECCAK256 = "KECCAK-256"; + private final MessageDigestImpl impl; private Hash(MessageDigestImpl impl) { @@ -85,7 +97,8 @@ private Hash(MessageDigestImpl impl) { /// #### Parameters /// /// - `algorithm`: one of [#MD5], [#SHA1], [#SHA224], [#SHA256], [#SHA384], - /// [#SHA512] -- case insensitive, with or without the dash + /// [#SHA512], [#SHA3_256], [#KECCAK256] -- case insensitive, with or + /// without the dash /// /// #### Returns /// @@ -184,6 +197,11 @@ public static byte[] sha512(byte[] data) { return create(SHA512).digest(data); } + /// One-shot Keccak-256 hash (Ethereum's hash, see [#KECCAK256]). + public static byte[] keccak256(byte[] data) { + return create(KECCAK256).digest(data); + } + // ---------------------------------------------------------------- // hex helpers -- handy for displaying digests and writing test vectors diff --git a/CodenameOne/src/com/codename1/security/Hmac.java b/CodenameOne/src/com/codename1/security/Hmac.java index a9ec68fbbb8..a9d52717738 100644 --- a/CodenameOne/src/com/codename1/security/Hmac.java +++ b/CodenameOne/src/com/codename1/security/Hmac.java @@ -97,6 +97,11 @@ private static int blockSizeFor(String algorithm) { if ("SHA384".equals(a) || "SHA512".equals(a)) { return 128; } + if ("SHA3256".equals(a) || "KECCAK256".equals(a)) { + // FIPS 202: the block size of a sponge is its rate, 136 bytes for + // a 256-bit output. + return 136; + } throw new CryptoException("unsupported HMAC algorithm: " + algorithm); } diff --git a/CodenameOne/src/com/codename1/security/MessageDigestImpl.java b/CodenameOne/src/com/codename1/security/MessageDigestImpl.java index b92d66c03ec..c9b09a17ffd 100644 --- a/CodenameOne/src/com/codename1/security/MessageDigestImpl.java +++ b/CodenameOne/src/com/codename1/security/MessageDigestImpl.java @@ -59,6 +59,12 @@ static MessageDigestImpl create(String algorithm) { if ("SHA512".equals(a)) { return new Sha512Family(false); } + if ("KECCAK256".equals(a)) { + return new Keccak(32, (byte) 0x01); + } + if ("SHA3256".equals(a)) { + return new Keccak(32, (byte) 0x06); + } throw new CryptoException("unsupported hash algorithm: " + algorithm); } @@ -736,6 +742,176 @@ private void processBlock(byte[] block, int o) { } } + // =============================================================== + // Keccak sponge over Keccak-f[1600] -- FIPS 202, with the original + // Keccak padding as an alternative domain byte. + // + // The two algorithms built on it differ in ONE byte: the first padding + // byte is 0x01 for the original Keccak (what Ethereum hashes with: block + // and transaction hashes, addresses, ABI selectors) and 0x06 for the + // standardised SHA3 (FIPS 202 appends the two domain bits 01 before the + // pad10*1). Everything else -- the permutation, the rate, the squeeze -- + // is shared, so one class covers both. + static final class Keccak extends MessageDigestImpl { + private static final long[] RC = { + 0x0000000000000001L, 0x0000000000008082L, 0x800000000000808aL, 0x8000000080008000L, + 0x000000000000808bL, 0x0000000080000001L, 0x8000000080008081L, 0x8000000000008009L, + 0x000000000000008aL, 0x0000000000000088L, 0x0000000080008009L, 0x000000008000000aL, + 0x000000008000808bL, 0x800000000000008bL, 0x8000000000008089L, 0x8000000000008003L, + 0x8000000000008002L, 0x8000000000000080L, 0x000000000000800aL, 0x800000008000000aL, + 0x8000000080008081L, 0x8000000000008080L, 0x0000000080000001L, 0x8000000080008008L + }; + + // rho rotation offsets, indexed by lane x + 5 * y + private static final int[] ROT = { + 0, 1, 62, 28, 27, + 36, 44, 6, 55, 20, + 3, 10, 43, 25, 39, + 41, 45, 15, 21, 8, + 18, 2, 61, 56, 14 + }; + + // pi: lane (x, y) moves to (y, 2x + 3y), again indexed by x + 5 * y + private static final int[] PI = new int[25]; + + static { + for (int x = 0; x < 5; x++) { + for (int y = 0; y < 5; y++) { + PI[x + 5 * y] = y + 5 * ((2 * x + 3 * y) % 5); + } + } + } + + private final int outLen; + private final int rate; + private final byte domain; + private final long[] state = new long[25]; + private final long[] scratch = new long[25]; + private final byte[] buffer; + private int bufferLen; + + Keccak(int outLen, byte domain) { + this.outLen = outLen; + this.rate = 200 - 2 * outLen; + this.domain = domain; + this.buffer = new byte[rate]; + } + + @Override + public void reset() { + for (int i = 0; i < 25; i++) { + state[i] = 0; + } + bufferLen = 0; + } + + @Override + public int digestLength() { + return outLen; + } + + @Override + void update(byte b) { + buffer[bufferLen++] = b; + if (bufferLen == rate) { + absorb(); + } + } + + @Override + void update(byte[] data, int offset, int length) { + int o = offset; + int remaining = length; + while (remaining > 0) { + int n = rate - bufferLen; + if (n > remaining) { + n = remaining; + } + System.arraycopy(data, o, buffer, bufferLen, n); + bufferLen += n; + o += n; + remaining -= n; + if (bufferLen == rate) { + absorb(); + } + } + } + + @Override + public byte[] digest() { + for (int i = bufferLen; i < rate; i++) { + buffer[i] = 0; + } + buffer[bufferLen] ^= domain; + buffer[rate - 1] ^= (byte) 0x80; + bufferLen = rate; + absorb(); + byte[] out = new byte[outLen]; + for (int i = 0; i < outLen; i++) { + out[i] = (byte) (state[i >> 3] >>> ((i & 7) << 3)); + } + reset(); + return out; + } + + // XORs the full buffer into the state lane by lane (little endian) and + // runs the permutation. + private void absorb() { + int lanes = rate >> 3; + for (int i = 0; i < lanes; i++) { + int o = i << 3; + long v = 0; + for (int b = 7; b >= 0; b--) { + v = (v << 8) | (buffer[o + b] & 0xffL); + } + state[i] ^= v; + } + bufferLen = 0; + permute(); + } + + private void permute() { + long[] a = state; + long[] b = scratch; + for (int round = 0; round < 24; round++) { + // theta + long c0 = a[0] ^ a[5] ^ a[10] ^ a[15] ^ a[20]; + long c1 = a[1] ^ a[6] ^ a[11] ^ a[16] ^ a[21]; + long c2 = a[2] ^ a[7] ^ a[12] ^ a[17] ^ a[22]; + long c3 = a[3] ^ a[8] ^ a[13] ^ a[18] ^ a[23]; + long c4 = a[4] ^ a[9] ^ a[14] ^ a[19] ^ a[24]; + long d0 = c4 ^ rotl(c1, 1); + long d1 = c0 ^ rotl(c2, 1); + long d2 = c1 ^ rotl(c3, 1); + long d3 = c2 ^ rotl(c4, 1); + long d4 = c3 ^ rotl(c0, 1); + for (int y = 0; y < 25; y += 5) { + a[y] ^= d0; + a[y + 1] ^= d1; + a[y + 2] ^= d2; + a[y + 3] ^= d3; + a[y + 4] ^= d4; + } + // rho and pi + for (int i = 0; i < 25; i++) { + b[PI[i]] = rotl(a[i], ROT[i]); + } + // chi + for (int y = 0; y < 25; y += 5) { + for (int x = 0; x < 5; x++) { + a[y + x] = b[y + x] ^ (~b[y + (x + 1) % 5] & b[y + (x + 2) % 5]); + } + } + // iota + a[0] ^= RC[round]; + } + } + + private static long rotl(long v, int s) { + return s == 0 ? v : (v << s) | (v >>> (64 - s)); + } + } + // =============================================================== // shared little helpers static void writeBE(byte[] out, int o, int v) { diff --git a/CodenameOne/src/com/codename1/security/PrivateKey.java b/CodenameOne/src/com/codename1/security/PrivateKey.java index 9bb2a968bc2..be0b2112cbd 100644 --- a/CodenameOne/src/com/codename1/security/PrivateKey.java +++ b/CodenameOne/src/com/codename1/security/PrivateKey.java @@ -32,10 +32,23 @@ /// level entry point for callers that already hold the DER bytes. public final class PrivateKey extends Key { + private byte[] signingForm; + PrivateKey(String algorithm, byte[] encoded, String format) { super(algorithm, encoded, format == null ? "PKCS#8" : format); } + /// The encoding handed to the port for signing: an EC key gains its + /// public point when it lacks one (see [EcKeys]), anything else is + /// [#getEncoded] as is. Computed once and kept. + byte[] signingForm() { + if (signingForm == null) { + byte[] enc = rawEncoded(); + signingForm = PublicKey.EC.equals(getAlgorithm()) ? EcKeys.withPublicPoint(enc) : enc; + } + return signingForm; + } + /// Wraps a PKCS#8 DER blob. This is the format produced by `openssl /// pkcs8 -topk8 -nocrypt`. public static PrivateKey fromPkcs8(String algorithm, byte[] pkcs8Der) { diff --git a/CodenameOne/src/com/codename1/security/Secp256k1.java b/CodenameOne/src/com/codename1/security/Secp256k1.java new file mode 100644 index 00000000000..b3720a2a2f2 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/Secp256k1.java @@ -0,0 +1,1119 @@ +/* + * Copyright (c) 2008-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +/// ECDSA over the secp256k1 curve, the signature scheme of Bitcoin and +/// Ethereum, written in portable Java so a wallet can sign on the device and +/// a server can verify or sign with the same class. +/// +/// Keys are raw bytes: a private key is 32 big-endian bytes in `[1, n-1]`, a +/// public key is the SEC 1 encoding of the point, either compressed (33 +/// bytes, `02`/`03` then `x`) or uncompressed (65 bytes, `04` then `x` then +/// `y`). Every method accepts a public key in either form. +/// +/// #### Signing an Ethereum transaction +/// +/// ```java +/// byte[] priv = Hash.fromHex("4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"); +/// +/// // the address is the last 20 bytes of keccak256(x || y) +/// byte[] pub = Secp256k1.publicKey(priv, false); // 04 || x || y +/// byte[] xy = new byte[64]; +/// System.arraycopy(pub, 1, xy, 0, 64); +/// byte[] digest = Hash.keccak256(xy); +/// byte[] address = new byte[20]; +/// System.arraycopy(digest, 12, address, 0, 20); // 2c7536e3605d9c16a7a3d7b1898e529396a65c23 +/// +/// // sign the keccak256 of the RLP-encoded transaction +/// byte[] rlp = Hash.fromHex("02c0"); // your encoded transaction +/// Secp256k1.EcdsaSignature sig = Secp256k1.sign(Hash.keccak256(rlp), priv); +/// byte[] r = sig.r(); +/// byte[] s = sig.s(); +/// int v = 27 + sig.recoveryId(); // or chainId * 2 + 35 + recoveryId +/// ``` +/// +/// Signatures are deterministic (RFC 6979, HMAC-SHA256) and always low-S, as +/// Bitcoin's standardness rules and Ethereum's EIP-2 require, and they carry +/// the recovery id Ethereum derives the signer's address from. [#verify] +/// rejects a high-S signature the way libsecp256k1 does; [#recoverPublicKey] +/// accepts either, the way the EVM's `ecrecover` does. A signature from +/// another library can be lowered with [EcdsaSignature#normalize]. +/// +/// #### Constant time +/// +/// Everything that touches a private key or a nonce -- key derivation, +/// signing, the tweak used by BIP32 derivation -- runs in constant time: the +/// field and scalar arithmetic uses fixed 16-bit limbs with no data-dependent +/// branches, the curve arithmetic uses complete addition formulas that have +/// no special case for doubling or the point at infinity, and the scalar +/// multiplication is a fixed-window ladder whose table lookup reads every +/// entry. Verification and recovery handle public data only and share the +/// same code. The implementation is independent of libsecp256k1 and +/// depends on nothing but this package and the core JDK subset, so it runs +/// identically on every port and on the backend. +@com.codename1.impl.SharedWithBackend +public final class Secp256k1 { + + /// Length in bytes of a private key. + public static final int PRIVATE_KEY_LENGTH = 32; + + /// Length in bytes of the message hash a signature covers. + public static final int HASH_LENGTH = 32; + + /// Length in bytes of a compressed public key (`02`/`03` then `x`). + public static final int COMPRESSED_PUBLIC_KEY_LENGTH = 33; + + /// Length in bytes of an uncompressed public key (`04` then `x` then `y`). + public static final int UNCOMPRESSED_PUBLIC_KEY_LENGTH = 65; + + // ---------------------------------------------------------------- + // Representation. A 256-bit integer is int[16] of 16-bit limbs, little + // endian. The limb is deliberately narrow: a product of two limbs is 32 + // bits and a column of sixteen of them 36, so a whole schoolbook product + // accumulates in a long with room to spare and the reduction never has + // to interleave carries with multiplications. That costs more multiplies + // than libsecp256k1's 26-bit limbs and buys a reduction that is a few + // short, obviously bounded loops -- see Modulus.reduce. + + private static final int LIMBS = 16; + private static final int LIMB_MASK = 0xffff; + private static final int LIMB_BITS = 16; + + private static final Modulus FP = new Modulus(limbs( + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F")); + private static final Modulus FN = new Modulus(limbs( + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141")); + + private static final int[] ZERO = new int[LIMBS]; + private static final int[] SEVEN = small(7); + // 3 * b, the constant the complete addition formulas multiply by + private static final int[] B3 = small(21); + // (p + 1) / 4 == (p >> 2) + 1 since p == 3 (mod 4): the square root exponent + private static final int[] SQRT_EXP = addSmall(shiftRight(FP.m, 2), 1); + private static final int[] N_HALF = shiftRight(FN.m, 1); + + private static final Pt G = Pt.affine( + limbs("79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"), + limbs("483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8")); + private static final Pt[] G_TABLE = table(G); + + private Secp256k1() { + } + + // ---------------------------------------------------------------- + // keys + + /// Tells whether the bytes are a usable private key: exactly 32 bytes + /// holding a big-endian integer in `[1, n-1]`. + public static boolean isValidPrivateKey(byte[] privateKey) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + return false; + } + return isValidScalar(toLimbs(privateKey, 0)); + } + + /// Derives the public key of a private key. + /// + /// #### Parameters + /// + /// - `privateKey`: 32 bytes in `[1, n-1]` + /// + /// - `compressed`: `true` for the 33-byte SEC 1 form, `false` for the + /// 65-byte form Ethereum derives an address from + /// + /// #### Throws + /// + /// - `CryptoException`: if the private key is not valid + public static byte[] publicKey(byte[] privateKey, boolean compressed) { + int[] d = privateScalar(privateKey); + Pt q = mul(G_TABLE, d); + wipe(d); + return serialize(q, compressed); + } + + /// Tells whether the bytes encode a point on the curve, in either SEC 1 + /// form. + public static boolean isValidPublicKey(byte[] publicKey) { + return parse(publicKey) != null; + } + + /// Re-encodes a public key in the requested form. + /// + /// #### Throws + /// + /// - `CryptoException`: if the key is not a point on the curve + public static byte[] convertPublicKey(byte[] publicKey, boolean compressed) { + return serialize(parseOrThrow(publicKey), compressed); + } + + /// Adds a tweak to a private key modulo the curve order: the private + /// half of a BIP32 child derivation. Constant time. + /// + /// #### Parameters + /// + /// - `privateKey`: 32 bytes in `[1, n-1]` + /// + /// - `tweak`: 32 bytes, less than `n` + /// + /// #### Returns + /// + /// `(privateKey + tweak) mod n`, 32 bytes + /// + /// #### Throws + /// + /// - `CryptoException`: if either input is out of range or the sum is zero + public static byte[] privateKeyTweakAdd(byte[] privateKey, byte[] tweak) { + int[] d = privateScalar(privateKey); + int[] t = tweakScalar(tweak); + int[] sum = new int[LIMBS]; + FN.add(d, t, sum); + wipe(d); + wipe(t); + if (isZero(sum)) { + throw new CryptoException("tweak produces a zero key"); + } + byte[] out = new byte[PRIVATE_KEY_LENGTH]; + toBytes(sum, out, 0); + wipe(sum); + return out; + } + + /// Adds `tweak * G` to a public key: the public half of a BIP32 child + /// derivation, matching [#privateKeyTweakAdd] on the private key. + /// + /// #### Throws + /// + /// - `CryptoException`: if the key is not on the curve, the tweak is not + /// less than `n`, or the result is the point at infinity + public static byte[] publicKeyTweakAdd(byte[] publicKey, byte[] tweak, boolean compressed) { + Pt q = parseOrThrow(publicKey); + int[] t = tweakScalar(tweak); + Pt tg = mul(G_TABLE, t); + Pt sum = new Pt(); + add(q, tg, sum); + if (sum.isInfinity()) { + throw new CryptoException("tweak produces the point at infinity"); + } + return serialize(sum, compressed); + } + + /// Adds two public keys as curve points. + /// + /// #### Throws + /// + /// - `CryptoException`: if either key is not on the curve or the sum is + /// the point at infinity + public static byte[] publicKeyAdd(byte[] a, byte[] b, boolean compressed) { + Pt sum = new Pt(); + add(parseOrThrow(a), parseOrThrow(b), sum); + if (sum.isInfinity()) { + throw new CryptoException("sum is the point at infinity"); + } + return serialize(sum, compressed); + } + + // ---------------------------------------------------------------- + // ECDSA + + /// Signs a 32-byte message hash. The nonce is derived from the key and + /// the hash as RFC 6979 specifies with HMAC-SHA256, so signing the same + /// hash twice gives the same signature and no random number generator is + /// involved. The result is always low-S and carries its recovery id. + /// + /// #### Parameters + /// + /// - `hash`: 32 bytes, the digest of the message (Keccak-256 of the + /// RLP-encoded transaction on Ethereum, double SHA-256 on Bitcoin) + /// + /// - `privateKey`: 32 bytes in `[1, n-1]` + /// + /// #### Throws + /// + /// - `CryptoException`: if the hash is not 32 bytes or the key is not valid + public static EcdsaSignature sign(byte[] hash, byte[] privateKey) { + checkLength(hash, HASH_LENGTH, "hash"); + int[] d = privateScalar(privateKey); + int[] z = FN.reduce(toLimbs(hash, 0)); + byte[] zBytes = new byte[HASH_LENGTH]; + toBytes(z, zBytes, 0); + + // RFC 6979 section 3.2, steps b to h + byte[] v = new byte[32]; + for (int i = 0; i < v.length; i++) { + v[i] = 1; + } + byte[] k = new byte[32]; + k = hmac(k, v, (byte) 0, privateKey, zBytes); + v = hmac(k, v); + k = hmac(k, v, (byte) 1, privateKey, zBytes); + v = hmac(k, v); + try { + // Each attempt fails with probability about 2^-128; the bound only + // turns an impossible infinite loop into an exception. + for (int attempt = 0; attempt < 128; attempt++) { + v = hmac(k, v); + int[] nonce = toLimbs(v, 0); + if (isValidScalar(nonce)) { + EcdsaSignature sig = signWithNonce(z, d, nonce); + wipe(nonce); + if (sig != null) { + return sig; + } + } + k = hmac(k, v, (byte) 0); + v = hmac(k, v); + } + } finally { + wipe(d); + wipe(k); + wipe(v); + } + throw new CryptoException("nonce generation failed"); + } + + /// Verifies a signature over a 32-byte hash. A signature whose `s` is in + /// the upper half of the range is rejected, as libsecp256k1 and the + /// Bitcoin and Ethereum networks reject it; see + /// [EcdsaSignature#normalize]. + /// + /// #### Returns + /// + /// `true` if the signature was made over `hash` by the holder of the + /// private key behind `publicKey` + /// + /// #### Throws + /// + /// - `CryptoException`: if the hash is not 32 bytes or the public key is + /// not a point on the curve + public static boolean verify(byte[] hash, EcdsaSignature signature, byte[] publicKey) { + checkLength(hash, HASH_LENGTH, "hash"); + if (signature == null) { + throw new CryptoException("signature must not be null"); + } + Pt q = parseOrThrow(publicKey); + int[] r = toLimbs(signature.r, 0); + int[] s = toLimbs(signature.s, 0); + if (!isValidScalar(r) || !isValidScalar(s) || lessThan(N_HALF, s)) { + return false; + } + int[] z = FN.reduce(toLimbs(hash, 0)); + int[] w = new int[LIMBS]; + FN.inv(s, w); + int[] u1 = new int[LIMBS]; + int[] u2 = new int[LIMBS]; + FN.mul(z, w, u1); + FN.mul(r, w, u2); + Pt sum = new Pt(); + add(mul(G_TABLE, u1), mul(table(q), u2), sum); + int[] x = new int[LIMBS]; + int[] y = new int[LIMBS]; + if (!toAffine(sum, x, y)) { + return false; + } + return equal(FN.reduce(x), r); + } + + /// Recovers the public key that made a signature, from the signature's + /// recovery id: what Ethereum does instead of carrying the sender's key + /// in a transaction. Unlike [#verify] this accepts a high-S signature, + /// as the EVM's `ecrecover` does. + /// + /// #### Parameters + /// + /// - `hash`: the 32 bytes that were signed + /// + /// - `signature`: a signature whose recovery id is known (0 to 3) + /// + /// - `compressed`: the form to return the key in + /// + /// #### Returns + /// + /// the public key, or `null` if no key could have produced the signature + /// + /// #### Throws + /// + /// - `CryptoException`: if the hash is not 32 bytes or the signature has + /// no recovery id + public static byte[] recoverPublicKey(byte[] hash, EcdsaSignature signature, boolean compressed) { + checkLength(hash, HASH_LENGTH, "hash"); + if (signature == null) { + throw new CryptoException("signature must not be null"); + } + int recId = signature.recoveryId; + if (recId < 0) { + throw new CryptoException("signature carries no recovery id"); + } + int[] r = toLimbs(signature.r, 0); + int[] s = toLimbs(signature.s, 0); + if (!isValidScalar(r) || !isValidScalar(s)) { + return null; + } + int[] x = r; + if ((recId & 2) != 0) { + // R.x was reduced by n once; undo it, and give up if that leaves + // the field (an event with probability about 2^-128) + x = new int[LIMBS]; + long carry = 0; + for (int i = 0; i < LIMBS; i++) { + long sum = (long) r[i] + FN.m[i] + carry; + x[i] = (int) (sum & LIMB_MASK); + carry = sum >> LIMB_BITS; + } + if (carry != 0 || !lessThan(x, FP.m)) { + return null; + } + } + int[] y = new int[LIMBS]; + if (!solveY(x, (recId & 1) != 0, y)) { + return null; + } + Pt rp = Pt.affine(x, y); + int[] z = FN.reduce(toLimbs(hash, 0)); + int[] rInv = new int[LIMBS]; + FN.inv(r, rInv); + int[] u1 = new int[LIMBS]; + int[] u2 = new int[LIMBS]; + FN.mul(z, rInv, u1); + FN.sub(ZERO, u1, u1); + FN.mul(s, rInv, u2); + Pt q = new Pt(); + add(mul(G_TABLE, u1), mul(table(rp), u2), q); + if (q.isInfinity()) { + return null; + } + return serialize(q, compressed); + } + + /// An ECDSA signature: `r` and `s` as 32-byte big-endian integers, and + /// the recovery id a signer knows and a verifier does not need. + public static final class EcdsaSignature { + private final byte[] r; + private final byte[] s; + private final int recoveryId; + + /// Creates a signature from its parts. + /// + /// #### Parameters + /// + /// - `r`: 32 bytes + /// + /// - `s`: 32 bytes + /// + /// - `recoveryId`: 0 to 3, or -1 when it is not known + /// + /// #### Throws + /// + /// - `CryptoException`: if a part has the wrong length or the + /// recovery id is out of range + public EcdsaSignature(byte[] r, byte[] s, int recoveryId) { + checkLength(r, 32, "r"); + checkLength(s, 32, "s"); + if (recoveryId < -1 || recoveryId > 3) { + throw new CryptoException("recovery id must be 0 to 3, or -1 when unknown"); + } + this.r = copy(r); + this.s = copy(s); + this.recoveryId = recoveryId; + } + + /// Creates a signature from the 64-byte `r || s` encoding. + /// + /// #### Parameters + /// + /// - `compact`: 64 bytes, `r` then `s` + /// + /// - `recoveryId`: 0 to 3, or -1 when it is not known + public static EcdsaSignature fromCompact(byte[] compact, int recoveryId) { + checkLength(compact, 64, "compact signature"); + byte[] r = new byte[32]; + byte[] s = new byte[32]; + System.arraycopy(compact, 0, r, 0, 32); + System.arraycopy(compact, 32, s, 0, 32); + return new EcdsaSignature(r, s, recoveryId); + } + + /// The `r` component, 32 big-endian bytes. + public byte[] r() { + return copy(r); + } + + /// The `s` component, 32 big-endian bytes. + public byte[] s() { + return copy(s); + } + + /// The recovery id, 0 to 3, or -1 when the signature was built + /// without one. Ethereum's `v` is this plus 27, or plus + /// `chainId * 2 + 35` under EIP-155. + public int recoveryId() { + return recoveryId; + } + + /// The 64-byte `r || s` encoding. + public byte[] toCompact() { + byte[] out = new byte[64]; + System.arraycopy(r, 0, out, 0, 32); + System.arraycopy(s, 0, out, 32, 32); + return out; + } + + /// Tells whether `s` is in the lower half of the range, the form + /// [Secp256k1#sign] produces and [Secp256k1#verify] accepts. + public boolean isLowS() { + return !lessThan(N_HALF, toLimbs(s, 0)); + } + + /// Returns the signature with `s` lowered to the other half of the + /// range when it is high, flipping the recovery id to match; a + /// signature that is already low-S is returned as it is. Both forms + /// are valid for the same key and hash, which is why networks accept + /// only one of them. + public EcdsaSignature normalize() { + if (isLowS()) { + return this; + } + int[] neg = new int[LIMBS]; + FN.sub(ZERO, toLimbs(s, 0), neg); + byte[] lowS = new byte[32]; + toBytes(neg, lowS, 0); + return new EcdsaSignature(r, lowS, recoveryId < 0 ? -1 : recoveryId ^ 1); + } + } + + // ---------------------------------------------------------------- + // signing internals + + // One RFC 6979 candidate. Returns null when r or s came out zero, which + // the caller answers with the next candidate. + private static EcdsaSignature signWithNonce(int[] z, int[] d, int[] nonce) { + Pt rp = mul(G_TABLE, nonce); + int[] rx = new int[LIMBS]; + int[] ry = new int[LIMBS]; + if (!toAffine(rp, rx, ry)) { + return null; + } + int[] r = FN.reduce(rx); + if (isZero(r)) { + return null; + } + int[] kInv = new int[LIMBS]; + FN.inv(nonce, kInv); + int[] s = new int[LIMBS]; + FN.mul(r, d, s); + FN.add(s, z, s); + FN.mul(s, kInv, s); + wipe(kInv); + if (isZero(s)) { + return null; + } + int recId = (ry[0] & 1) | (lessThan(rx, FN.m) ? 0 : 2); + if (lessThan(N_HALF, s)) { + FN.sub(ZERO, s, s); + recId ^= 1; + } + byte[] rBytes = new byte[32]; + byte[] sBytes = new byte[32]; + toBytes(r, rBytes, 0); + toBytes(s, sBytes, 0); + return new EcdsaSignature(rBytes, sBytes, recId); + } + + private static byte[] hmac(byte[] key, byte[] v) { + return Hmac.sha256(key, v); + } + + private static byte[] hmac(byte[] key, byte[] v, byte separator) { + Hmac h = Hmac.create(Hash.SHA256, key); + h.update(v); + h.update(new byte[] {separator}); + return h.doFinal(); + } + + private static byte[] hmac(byte[] key, byte[] v, byte separator, byte[] x, byte[] h1) { + Hmac h = Hmac.create(Hash.SHA256, key); + h.update(v); + h.update(new byte[] {separator}); + h.update(x); + h.update(h1); + return h.doFinal(); + } + + // ---------------------------------------------------------------- + // encoding + + private static int[] privateScalar(byte[] privateKey) { + checkLength(privateKey, PRIVATE_KEY_LENGTH, "private key"); + int[] d = toLimbs(privateKey, 0); + if (!isValidScalar(d)) { + wipe(d); + throw new CryptoException("private key must be in [1, n-1]"); + } + return d; + } + + private static int[] tweakScalar(byte[] tweak) { + checkLength(tweak, PRIVATE_KEY_LENGTH, "tweak"); + int[] t = toLimbs(tweak, 0); + if (!lessThan(t, FN.m)) { + wipe(t); + throw new CryptoException("tweak must be less than n"); + } + return t; + } + + private static Pt parseOrThrow(byte[] publicKey) { + Pt p = parse(publicKey); + if (p == null) { + throw new CryptoException("not a secp256k1 public key"); + } + return p; + } + + // null when the bytes are not a point on the curve in either SEC 1 form + private static Pt parse(byte[] key) { + if (key == null) { + return null; + } + if (key.length == COMPRESSED_PUBLIC_KEY_LENGTH && (key[0] == 2 || key[0] == 3)) { + int[] x = toLimbs(key, 1); + if (!lessThan(x, FP.m)) { + return null; + } + int[] y = new int[LIMBS]; + if (!solveY(x, key[0] == 3, y)) { + return null; + } + return Pt.affine(x, y); + } + if (key.length == UNCOMPRESSED_PUBLIC_KEY_LENGTH && key[0] == 4) { + int[] x = toLimbs(key, 1); + int[] y = toLimbs(key, 33); + if (!lessThan(x, FP.m) || !lessThan(y, FP.m)) { + return null; + } + int[] rhs = new int[LIMBS]; + curveRhs(x, rhs); + int[] lhs = new int[LIMBS]; + FP.mul(y, y, lhs); + if (!equal(lhs, rhs)) { + return null; + } + return Pt.affine(x, y); + } + return null; + } + + private static byte[] serialize(Pt p, boolean compressed) { + int[] x = new int[LIMBS]; + int[] y = new int[LIMBS]; + if (!toAffine(p, x, y)) { + throw new CryptoException("point at infinity has no encoding"); + } + byte[] out; + if (compressed) { + out = new byte[COMPRESSED_PUBLIC_KEY_LENGTH]; + out[0] = (byte) (2 + (y[0] & 1)); + } else { + out = new byte[UNCOMPRESSED_PUBLIC_KEY_LENGTH]; + out[0] = 4; + toBytes(y, out, 33); + } + toBytes(x, out, 1); + return out; + } + + // x^3 + 7 + private static void curveRhs(int[] x, int[] out) { + int[] t = new int[LIMBS]; + FP.mul(x, x, t); + FP.mul(t, x, t); + FP.add(t, SEVEN, out); + } + + // y with the requested parity such that y^2 == x^3 + 7; false when x is + // not the abscissa of a curve point + private static boolean solveY(int[] x, boolean odd, int[] yOut) { + int[] rhs = new int[LIMBS]; + curveRhs(x, rhs); + int[] y = new int[LIMBS]; + FP.pow(rhs, SQRT_EXP, y); + int[] check = new int[LIMBS]; + FP.mul(y, y, check); + if (!equal(check, rhs)) { + return false; + } + if (((y[0] & 1) == 1) != odd) { + FP.sub(ZERO, y, y); + } + System.arraycopy(y, 0, yOut, 0, LIMBS); + return true; + } + + private static void checkLength(byte[] data, int length, String what) { + if (data == null) { + throw new CryptoException(what + " must not be null"); + } + if (data.length != length) { + throw new CryptoException(what + " must be " + length + " bytes, not " + data.length); + } + } + + private static byte[] copy(byte[] a) { + byte[] out = new byte[a.length]; + System.arraycopy(a, 0, out, 0, a.length); + return out; + } + + private static void wipe(int[] a) { + for (int i = 0; i < a.length; i++) { + a[i] = 0; + } + } + + private static void wipe(byte[] a) { + for (int i = 0; i < a.length; i++) { + a[i] = 0; + } + } + + // ---------------------------------------------------------------- + // 256-bit integers as limbs + + private static int[] limbs(String hex) { + return toLimbs(Hash.fromHex(hex), 0); + } + + private static int[] small(int v) { + int[] out = new int[LIMBS]; + out[0] = v & LIMB_MASK; + out[1] = v >>> LIMB_BITS; + return out; + } + + // 32 big-endian bytes to limbs + private static int[] toLimbs(byte[] in, int off) { + int[] out = new int[LIMBS]; + for (int i = 0; i < LIMBS; i++) { + int o = off + 30 - 2 * i; + out[i] = ((in[o] & 0xff) << 8) | (in[o + 1] & 0xff); + } + return out; + } + + private static void toBytes(int[] a, byte[] out, int off) { + for (int i = 0; i < LIMBS; i++) { + int o = off + 30 - 2 * i; + out[o] = (byte) (a[i] >>> 8); + out[o + 1] = (byte) a[i]; + } + } + + private static int[] addSmall(int[] a, int c) { + int[] out = new int[LIMBS]; + int carry = c; + for (int i = 0; i < LIMBS; i++) { + int v = a[i] + carry; + out[i] = v & LIMB_MASK; + carry = v >>> LIMB_BITS; + } + return out; + } + + private static int[] subSmall(int[] a, int c) { + int[] out = new int[LIMBS]; + int borrow = c; + for (int i = 0; i < LIMBS; i++) { + int v = a[i] - borrow; + out[i] = v & LIMB_MASK; + borrow = (v >> 31) & 1; + } + return out; + } + + // a >> bits, for 0 < bits < 16 + private static int[] shiftRight(int[] a, int bits) { + int[] out = new int[LIMBS]; + for (int i = 0; i < LIMBS - 1; i++) { + out[i] = ((a[i] >>> bits) | (a[i + 1] << (LIMB_BITS - bits))) & LIMB_MASK; + } + out[LIMBS - 1] = a[LIMBS - 1] >>> bits; + return out; + } + + // a < b, computed without a data-dependent branch + private static boolean lessThan(int[] a, int[] b) { + long borrow = 0; + for (int i = 0; i < LIMBS; i++) { + long d = (long) a[i] - b[i] - borrow; + borrow = (d >> 63) & 1; + } + return borrow != 0; + } + + private static boolean isZero(int[] a) { + int acc = 0; + for (int i = 0; i < LIMBS; i++) { + acc |= a[i]; + } + return acc == 0; + } + + private static boolean equal(int[] a, int[] b) { + int acc = 0; + for (int i = 0; i < LIMBS; i++) { + acc |= a[i] ^ b[i]; + } + return acc == 0; + } + + // 0 < v < n + private static boolean isValidScalar(int[] v) { + return !isZero(v) && lessThan(v, FN.m); + } + + // ---------------------------------------------------------------- + // arithmetic modulo a 256-bit prime of the form 2^256 - k with k < 2^129 + // (both secp256k1 primes are): the field p and the group order n + + private static final class Modulus { + // the modulus + final int[] m; + // 2^256 - m, little endian, klen limbs long (3 for p, 9 for n) + private final int[] k = new int[LIMBS]; + private final int klen; + // m - 2, the exponent that inverts by Fermat + private final int[] minusTwo; + + Modulus(int[] m) { + this.m = m; + int carry = 1; + for (int i = 0; i < LIMBS; i++) { + int v = (~m[i] & LIMB_MASK) + carry; + k[i] = v & LIMB_MASK; + carry = v >>> LIMB_BITS; + } + int len = 0; + for (int i = 0; i < LIMBS; i++) { + if (k[i] != 0) { + len = i + 1; + } + } + klen = len; + minusTwo = subSmall(m, 2); + } + + // out = a * b mod m. out may be a or b. + void mul(int[] a, int[] b, int[] out) { + long[] c = new long[2 * LIMBS]; + for (int i = 0; i < LIMBS; i++) { + long ai = a[i]; + for (int j = 0; j < LIMBS; j++) { + c[i + j] += ai * b[j]; + } + } + reduce(c, out); + } + + // out = a + b mod m, for a, b < m. out may be a or b. + void add(int[] a, int[] b, int[] out) { + long[] c = new long[2 * LIMBS]; + long carry = 0; + for (int i = 0; i < LIMBS; i++) { + long s = (long) a[i] + b[i] + carry; + c[i] = s & LIMB_MASK; + carry = s >> LIMB_BITS; + } + c[LIMBS] = carry; + conditionalSubtract(c, out); + } + + // out = a - b mod m, for a, b < m. out may be a or b. + void sub(int[] a, int[] b, int[] out) { + int[] d = new int[LIMBS]; + long borrow = 0; + for (int i = 0; i < LIMBS; i++) { + long v = (long) a[i] - b[i] - borrow; + d[i] = (int) (v & LIMB_MASK); + borrow = (v >> 63) & 1; + } + // a < b: the difference wrapped past zero, so add m back (the + // carry out of the top limb undoes the wrap) + int mask = (int) -borrow; + long carry = 0; + for (int i = 0; i < LIMBS; i++) { + long s = (long) d[i] + (m[i] & mask) + carry; + out[i] = (int) (s & LIMB_MASK); + carry = s >> LIMB_BITS; + } + } + + // out = a^(m-2) == 1/a mod m (0 maps to 0). out may be a. + void inv(int[] a, int[] out) { + pow(a, minusTwo, out); + } + + // out = base^exp mod m by square-and-multiply over the bits of exp. + // Every exponent used here is a public constant, so the branch on + // its bits leaks nothing about base. out may be base. + void pow(int[] base, int[] exp, int[] out) { + int[] b = new int[LIMBS]; + System.arraycopy(base, 0, b, 0, LIMBS); + int[] r = new int[LIMBS]; + r[0] = 1; + for (int bit = 255; bit >= 0; bit--) { + mul(r, r, r); + if (((exp[bit >> 4] >> (bit & 15)) & 1) != 0) { + mul(r, b, r); + } + } + System.arraycopy(r, 0, out, 0, LIMBS); + } + + // v mod m for any v < 2^256 (one conditional subtraction, since + // m > 2^255) + int[] reduce(int[] v) { + long[] c = new long[2 * LIMBS]; + for (int i = 0; i < LIMBS; i++) { + c[i] = v[i]; + } + int[] out = new int[LIMBS]; + conditionalSubtract(c, out); + return out; + } + + // Reduces a 32-column product (each column below 2^36) to out < m. + // + // 2^256 == k (mod m), so a column j >= 16 is worth c[j] * k at column + // j - 16. Each fold pass below moves whole columns down that way and + // each carry pass renormalises every column to 16 bits; the passes + // are ordered so that no column ever exceeds about 2^56: + // + // 1. columns 24..31 fold into 8..23 (k has at most 9 limbs, so + // nothing lands at or above 24 again); the value is now < 2^386 + // 2. columns 16..24 fold into 0..16; the value is now < 2^260 + // 3. column 16 (at most 4 bits) folds; the value is now < 2^256 + 2^134 + // 4. column 16 (at most 1 bit) folds; the value is now < 2^256 + // 5. one conditional subtraction of m + // + // Every loop has a fixed trip count, so this is constant time. + private void reduce(long[] c, int[] out) { + carry(c); + fold(c, 2 * LIMBS - 1, 24); + carry(c); + fold(c, 24, LIMBS); + carry(c); + fold(c, LIMBS, LIMBS); + carry(c); + fold(c, LIMBS, LIMBS); + carry(c); + conditionalSubtract(c, out); + } + + // folds columns hi down to lo, each into the klen columns 16 below it + private void fold(long[] c, int hi, int lo) { + for (int j = hi; j >= lo; j--) { + long v = c[j]; + c[j] = 0; + int base = j - LIMBS; + for (int i = 0; i < klen; i++) { + c[base + i] += v * k[i]; + } + } + } + + private static void carry(long[] c) { + for (int i = 0; i < 2 * LIMBS - 1; i++) { + c[i + 1] += c[i] >> LIMB_BITS; + c[i] &= LIMB_MASK; + } + } + + // out = c mod m for a normalised c < 2m whose bit 256 is c[16] + private void conditionalSubtract(long[] c, int[] out) { + int[] t = new int[LIMBS]; + long borrow = 0; + for (int i = 0; i < LIMBS; i++) { + long d = c[i] - m[i] - borrow; + t[i] = (int) (d & LIMB_MASK); + borrow = (d >> 63) & 1; + } + // c >= m when the bit above the top limb is set or no borrow came out + int mask = (int) -(c[LIMBS] | (borrow ^ 1)); + for (int i = 0; i < LIMBS; i++) { + out[i] = (t[i] & mask) | ((int) c[i] & ~mask); + } + } + } + + // ---------------------------------------------------------------- + // the curve, in homogeneous projective coordinates (X : Y : Z) with + // x = X / Z and y = Y / Z; the point at infinity is (0 : 1 : 0) + + private static final class Pt { + final int[] x = new int[LIMBS]; + final int[] y = new int[LIMBS]; + final int[] z = new int[LIMBS]; + + static Pt infinity() { + Pt p = new Pt(); + p.y[0] = 1; + return p; + } + + static Pt affine(int[] x, int[] y) { + Pt p = new Pt(); + System.arraycopy(x, 0, p.x, 0, LIMBS); + System.arraycopy(y, 0, p.y, 0, LIMBS); + p.z[0] = 1; + return p; + } + + void set(Pt o) { + System.arraycopy(o.x, 0, x, 0, LIMBS); + System.arraycopy(o.y, 0, y, 0, LIMBS); + System.arraycopy(o.z, 0, z, 0, LIMBS); + } + + boolean isInfinity() { + return isZero(z); + } + } + + // out = p + q by the complete addition law for y^2 = x^3 + b (Renes, + // Costello and Batina, 2016, with a = 0 and b3 = 3b): + // + // X3 = (X1 Y2 + X2 Y1)(Y1 Y2 - b3 Z1 Z2) - b3 (Y1 Z2 + Y2 Z1)(X1 Z2 + X2 Z1) + // Y3 = (Y1 Y2 + b3 Z1 Z2)(Y1 Y2 - b3 Z1 Z2) + 3 b3 X1 X2 (X1 Z2 + X2 Z1) + // Z3 = (Y1 Z2 + Y2 Z1)(Y1 Y2 + b3 Z1 Z2) + 3 X1 X2 (X1 Y2 + X2 Y1) + // + // It is correct for every pair of inputs -- p == q, p == -q, either one + // the point at infinity -- so it doubles as the doubling and there is no + // branch for the ladder to leak through. A dedicated doubling formula + // would save about a third of the multiplications; this keeps one + // formula. out may be p or q. + private static void add(Pt p, Pt q, Pt out) { + int[] t0 = new int[LIMBS]; + int[] t1 = new int[LIMBS]; + int[] t2 = new int[LIMBS]; + int[] t3 = new int[LIMBS]; + int[] t4 = new int[LIMBS]; + int[] t5 = new int[LIMBS]; + int[] t6 = new int[LIMBS]; + int[] t7 = new int[LIMBS]; + FP.mul(p.x, q.x, t0); // X1 X2 + FP.mul(p.y, q.y, t1); // Y1 Y2 + FP.mul(p.z, q.z, t2); // Z1 Z2 + FP.add(p.x, p.y, t3); + FP.add(q.x, q.y, t4); + FP.mul(t3, t4, t3); + FP.add(t0, t1, t4); + FP.sub(t3, t4, t3); // X1 Y2 + X2 Y1 + FP.add(p.y, p.z, t4); + FP.add(q.y, q.z, t5); + FP.mul(t4, t5, t4); + FP.add(t1, t2, t5); + FP.sub(t4, t5, t4); // Y1 Z2 + Y2 Z1 + FP.add(p.x, p.z, t5); + FP.add(q.x, q.z, t6); + FP.mul(t5, t6, t5); + FP.add(t0, t2, t6); + FP.sub(t5, t6, t5); // X1 Z2 + X2 Z1 + FP.add(t0, t0, t6); + FP.add(t6, t0, t0); // 3 X1 X2 + FP.mul(B3, t2, t2); // b3 Z1 Z2 + FP.add(t1, t2, t6); // Y1 Y2 + b3 Z1 Z2 + FP.sub(t1, t2, t1); // Y1 Y2 - b3 Z1 Z2 + FP.mul(B3, t5, t5); // b3 (X1 Z2 + X2 Z1) + FP.mul(t3, t1, t2); + FP.mul(t4, t5, t7); + FP.sub(t2, t7, out.x); + FP.mul(t6, t1, t1); + FP.mul(t0, t5, t7); + FP.add(t1, t7, out.y); + FP.mul(t4, t6, t6); + FP.mul(t0, t3, t7); + FP.add(t6, t7, out.z); + } + + // the multiples 0..15 of p, for mul + private static Pt[] table(Pt p) { + Pt[] t = new Pt[16]; + t[0] = Pt.infinity(); + t[1] = new Pt(); + t[1].set(p); + for (int i = 2; i < t.length; i++) { + t[i] = new Pt(); + add(t[i - 1], p, t[i]); + } + return t; + } + + // scalar * p, where table is table(p) and 0 <= scalar < 2^256, by a + // fixed window of four bits: 64 windows, each four doublings and one + // addition of the table entry the window selects. The entry is read with + // a constant-time scan of the whole table, so neither the sequence of + // operations nor the memory addresses touched depend on the scalar. + private static Pt mul(Pt[] table, int[] scalar) { + Pt r = Pt.infinity(); + Pt t = new Pt(); + for (int w = 63; w >= 0; w--) { + add(r, r, r); + add(r, r, r); + add(r, r, r); + add(r, r, r); + int digit = (scalar[w >> 2] >> ((w & 3) << 2)) & 0xf; + select(table, digit, t); + add(r, t, r); + } + return r; + } + + // out = table[index] without indexing by index + private static void select(Pt[] table, int index, Pt out) { + wipe(out.x); + wipe(out.y); + wipe(out.z); + for (int e = 0; e < table.length; e++) { + // -1 when e == index, else 0 + int mask = ((e ^ index) - 1) >> 31; + Pt entry = table[e]; + for (int i = 0; i < LIMBS; i++) { + out.x[i] |= entry.x[i] & mask; + out.y[i] |= entry.y[i] & mask; + out.z[i] |= entry.z[i] & mask; + } + } + } + + // false for the point at infinity + private static boolean toAffine(Pt p, int[] xOut, int[] yOut) { + if (p.isInfinity()) { + return false; + } + int[] zInv = new int[LIMBS]; + FP.inv(p.z, zInv); + FP.mul(p.x, zInv, xOut); + FP.mul(p.y, zInv, yOut); + return true; + } +} diff --git a/CodenameOne/src/com/codename1/security/SecureStorage.java b/CodenameOne/src/com/codename1/security/SecureStorage.java index 38cb2bf8eb5..471c6299273 100644 --- a/CodenameOne/src/com/codename1/security/SecureStorage.java +++ b/CodenameOne/src/com/codename1/security/SecureStorage.java @@ -53,7 +53,8 @@ /// Team-ID-prefixed group identifier. /// - **Android** -- AES/CBC/PKCS7 ciphertext stored in `SharedPreferences` /// with the key in the `AndroidKeyStore`, locked via -/// `setUserAuthenticationRequired(true)`. The `BiometricPrompt` (API 29+) +/// `setUserAuthenticationRequired(true)` below API 33, and on every level +/// with [#setStrictBiometricBinding(boolean)]. The `BiometricPrompt` (API 29+) /// or `FingerprintManager` (API 23-28) unlocks the cipher for one /// operation per prompt. /// - **JavaSE simulator** -- backed by `java.util.prefs.Preferences`, gated @@ -117,6 +118,42 @@ public AsyncResource remove(String reason, String account) { return r; } + private boolean strictBiometricBinding; + + /// Opts in to the strict binding for keys this storage creates from now + /// on. Off by default. + /// + /// On Android, the default key is user-authentication bound below API 33 + /// only: on 33+ the keystore binding is skipped (FingerprintScanner #8, + /// where a bound key failed to recover for existing users), so there the + /// prompt guards the entry but the key itself would decrypt without one. + /// With this on, a key the storage creates is bound on every API level + /// and, on API 24+, invalidated when a biometric is enrolled -- so + /// entries fail with [BiometricError#KEY_REVOKED] after a new fingerprint + /// or face is added, exactly as this class's contract describes. + /// + /// It applies only to a key created after the call. A key that already + /// exists -- an installed app's, made under the old rules -- is kept and + /// used as it is, never deleted for being unbound, because deleting it + /// would destroy every entry the app stored. So call this once at + /// startup, before the first write, in an app that wants the binding + /// from its first install. Other platforms already bind their entries + /// (iOS `kSecAccessControlTouchIDCurrentSet`) and ignore it. + public void setStrictBiometricBinding(boolean strict) { + strictBiometricBinding = strict; + } + + /// Whether [#setStrictBiometricBinding(boolean)] is on. This is the + /// policy for keys created from now on, not a statement about the key in + /// use: on Android a key that existed before the call (made unbound on + /// API 33+) is kept as it is, so an installed app that turned this on in + /// an update can answer true here while its entries still sit under an + /// unbound key. Do not report it to the user as "the stored data is bound + /// to the biometric" without that caveat. + public boolean isStrictBiometricBinding() { + return strictBiometricBinding; + } + /// Configures the iOS keychain access group for sharing entries between /// the main app and its extensions. The argument must include the Team /// ID prefix (e.g. `"ABCDE12345.group.com.example.app"`). Pass `null` diff --git a/CodenameOne/src/com/codename1/security/Signature.java b/CodenameOne/src/com/codename1/security/Signature.java index 53e146f7d26..a73b59ffc34 100644 --- a/CodenameOne/src/com/codename1/security/Signature.java +++ b/CodenameOne/src/com/codename1/security/Signature.java @@ -59,7 +59,7 @@ private Signature() {} /// Signs `data` with the given algorithm and private key. public static byte[] sign(String algorithm, PrivateKey key, byte[] data) { try { - return Util.cryptoSign(algorithm, key.getAlgorithm(), key.getEncoded(), data); + return Util.cryptoSign(algorithm, key.getAlgorithm(), key.signingForm(), data); } catch (RuntimeException re) { throw new CryptoException(re.getMessage(), re); } diff --git a/CodenameOne/src/com/codename1/ui/ClipboardContent.java b/CodenameOne/src/com/codename1/ui/ClipboardContent.java index d07d42023ff..2ac44fd28bd 100644 --- a/CodenameOne/src/com/codename1/ui/ClipboardContent.java +++ b/CodenameOne/src/com/codename1/ui/ClipboardContent.java @@ -52,6 +52,67 @@ public class ClipboardContent { private final List mimeTypes = new ArrayList(); private final List values = new ArrayList(); + private boolean sensitive; + private int clearAfter; + + /// Marks this content as sensitive: a password, a one-time code, a recovery key, an account + /// number. Each port hides or confines it as far as its platform allows: + /// + /// - Android (API 33 and newer) tags the clip with `ClipDescription.EXTRA_IS_SENSITIVE`, so + /// the system's copy preview and keyboard suggestions do not show its text. + /// - iOS publishes it local only (`UIPasteboardOptionLocalOnly`), so Universal Clipboard + /// never sends it to the user's other devices. + /// - The simulator and the browser have no such marker; there the flag only matters together + /// with `#setClearAfter(int)`. + /// + /// #### Parameters + /// + /// - `sensitive`: true to mark the content sensitive + /// + /// #### Returns + /// + /// this instance, for chaining + public ClipboardContent setSensitive(boolean sensitive) { + this.sensitive = sensitive; + return this; + } + + /// Whether `#setSensitive(boolean)` marked this content sensitive. + public boolean isSensitive() { + return sensitive; + } + + /// Takes the content off the clipboard again after the given time. iOS lets the pasteboard + /// expire it (`UIPasteboardOptionExpirationDate`), which works even if the application is + /// suspended or killed meanwhile. Elsewhere the application clears the clipboard when the + /// time is up, provided it has copied nothing else since and the clipboard still holds this + /// content -- a copy another application made meanwhile is left alone. That needs the + /// application to be running, so it is a best effort rather than a guarantee. On Android 10 + /// and later a background application cannot read the clipboard, so it cannot tell whether + /// the clip is still this content; the clear then waits until the application returns to + /// the foreground and runs only if the clipboard still holds this content. Until then the + /// content can outlive the time given here. Outside iOS the pending clear lives only in + /// the running process: if the application is killed before it runs, nothing clears the + /// content. An application that must not leave it behind records a digest of what it copied + /// (never the text) and, at its next start, clears the clipboard if it still holds content + /// with that digest. + /// + /// #### Parameters + /// + /// - `millis`: the time the content stays on the clipboard, 0 (the default) to keep it + /// + /// #### Returns + /// + /// this instance, for chaining + public ClipboardContent setClearAfter(int millis) { + this.clearAfter = millis < 0 ? 0 : millis; + return this; + } + + /// The time `#setClearAfter(int)` set, in milliseconds, or 0. + public int getClearAfter() { + return clearAfter; + } /// Adds or replaces a representation. Passing null removes the MIME type. public ClipboardContent setData(String mimeType, Object value) { diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 144056b7572..338f9f9dbf1 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -6224,16 +6224,38 @@ public void installNativeTheme() { /// image objects be used to copy public void copyToClipboard(Object obj) { if (obj instanceof ClipboardContent) { - forgetProvidedValues((ClipboardContent) obj); + copyToClipboard((ClipboardContent) obj); + return; } + clipboardCopies++; impl.copyToClipboard(obj); } + /// Counts the copies made through this class, so a scheduled clear can tell whether the + /// content it was set up for is still the latest one this application copied. + private int clipboardCopies; + /// Copies a set of alternative clipboard representations. The first entry should normally be /// `text/plain`; richer consumers can negotiate HTML, RTF, Markdown, AsciiDoc, or custom MIME data. public void copyToClipboard(ClipboardContent content) { forgetProvidedValues(content); + clipboardCopies++; impl.copyToClipboard(content); + if (content != null && content.getClearAfter() > 0) { + final int copy = clipboardCopies; + final ClipboardContent copied = content; + setTimeout(content.getClearAfter(), new Runnable() { + @Override + public void run() { + // Only while nothing else was copied through this application since: a + // later copy is the user's, and clearing it would throw away what they + // just asked for. + if (copy == clipboardCopies) { + impl.clearClipboardContent(copied); + } + } + }); + } } /// Ends the previous transfer's memory of what its providers produced. diff --git a/CodenameOne/src/com/codename1/util/BigDecimal.java b/CodenameOne/src/com/codename1/util/BigDecimal.java index bc885d84908..f8a72c66261 100644 --- a/CodenameOne/src/com/codename1/util/BigDecimal.java +++ b/CodenameOne/src/com/codename1/util/BigDecimal.java @@ -120,12 +120,13 @@ public int compareTo(BigInteger val) { public BigInteger floor() { BigInteger out = new BigInteger(peer.toBigInteger()); - if (peer.signum() < 0) { - return out.subtract(new BigInteger("1", 0)); + if (peer.signum() < 0 && peer.compareTo(new TBigDecimal(out.peer, 0)) != 0) { + return out.subtract(BigInteger.ONE); } return out; } + /// Rounds to the nearest integer, a tie going away from zero (HALF_UP): 2.5 is 3 and -2.5 is -3. public BigInteger round() { BigInteger out = new BigInteger(peer.toBigInteger()); BigDecimal outD = new BigDecimal(out, 0); @@ -134,9 +135,13 @@ public BigInteger round() { BigDecimal nextD = new BigDecimal(next, 0); BigDecimal diffThis = new BigDecimal(outD.peer.abs().subtract(peer.abs()).abs()); - BigDecimal diffNext = new BigDecimal(outD.peer.abs().subtract(nextD.peer.abs()).abs()); + // Distance from this value to the next integer away from zero. It used to + // measure out-to-next, which is always 1, so every value rounded away + // from zero; a wrong bitLength(int) in TBigDecimal hid that until the + // comparison below started reading the real magnitudes. + BigDecimal diffNext = new BigDecimal(nextD.peer.abs().subtract(peer.abs()).abs()); - return diffThis.compareTo(diffNext) > 0 ? out : next; + return diffThis.compareTo(diffNext) < 0 ? out : next; } diff --git a/CodenameOne/src/com/codename1/util/TBigDecimal.java b/CodenameOne/src/com/codename1/util/TBigDecimal.java index d0c7d816aa0..f1048e57f02 100644 --- a/CodenameOne/src/com/codename1/util/TBigDecimal.java +++ b/CodenameOne/src/com/codename1/util/TBigDecimal.java @@ -1039,7 +1039,7 @@ private static int bitLength(int smallValue) { if (smallValue < 0) { smallValue = ~smallValue; } - return 32 - numberOfLeadingZeros(smallValue); + return 32 - TBitLevel.numberOfLeadingZeros(smallValue); } /// Returns a new `BigDecimal` whose value is `this + augend`. diff --git a/CodenameOne/src/com/codename1/util/TBigInteger.java b/CodenameOne/src/com/codename1/util/TBigInteger.java index 9eac684bbe0..2a8095d7fb1 100644 --- a/CodenameOne/src/com/codename1/util/TBigInteger.java +++ b/CodenameOne/src/com/codename1/util/TBigInteger.java @@ -1358,7 +1358,7 @@ public TBigInteger modInverse(TBigInteger m) { } // From now on: (m > 1) - TBigInteger res = TDivision.modInverseMontgomery(abs().mod(m), m); + TBigInteger res = TDivision.modInverseEuclid(abs().mod(m), m); if (res.sign == 0) { throw new ArithmeticException("BigInteger not invertible."); } diff --git a/CodenameOne/src/com/codename1/util/TBitLevel.java b/CodenameOne/src/com/codename1/util/TBitLevel.java index dac61808323..b06a645bb9f 100644 --- a/CodenameOne/src/com/codename1/util/TBitLevel.java +++ b/CodenameOne/src/com/codename1/util/TBitLevel.java @@ -104,14 +104,14 @@ static int bitCount(TBigInteger val) { int i = val.getFirstNonzeroDigit(); if (val.sign > 0) { for (; i < val.numberLength; i++) { - bCount += TBigDecimal.bitCount(val.digits[i]); + bCount += TBigDecimal.bitCount(val.digits[i] & 0xffffffffL); } } else { // (sign < 0) // this digit absorbs the carry - bCount += TBigDecimal.bitCount(-val.digits[i]); + bCount += TBigDecimal.bitCount(-val.digits[i] & 0xffffffffL); for (i++; i < val.numberLength; i++) { - bCount += TBigDecimal.bitCount(~val.digits[i]); + bCount += TBigDecimal.bitCount(~val.digits[i] & 0xffffffffL); } // We take the complement sum: bCount = (val.numberLength << 5) - bCount; @@ -165,7 +165,7 @@ static TBigInteger shiftLeft(TBigInteger source, int count) { static void inplaceShiftLeft(TBigInteger val, int count) { int intCount = count >> 5; // count of integers val.numberLength += intCount + - (TBigDecimal.numberOfLeadingZeros(val.digits[val.numberLength - 1]) - (count & 31) >= 0 ? 0 : 1); + (numberOfLeadingZeros(val.digits[val.numberLength - 1]) - (count & 31) >= 0 ? 0 : 1); shiftLeft(val.digits, val.digits, intCount, count & 31); val.cutOffLeadingZeroes(); val.unCache(); diff --git a/CodenameOne/src/com/codename1/util/TDivision.java b/CodenameOne/src/com/codename1/util/TDivision.java index faa25f034fb..4ab4e09b3cc 100644 --- a/CodenameOne/src/com/codename1/util/TDivision.java +++ b/CodenameOne/src/com/codename1/util/TDivision.java @@ -70,7 +70,7 @@ static int[] divide(int[] quot, int quotLength, int[] a, int aLength, int[] b, i * Step D1: normalize a and b and put the results to a1 and b1 the * normalized divisor's first digit must be >= 2^31 */ - int divisorShift = TBigDecimal.numberOfLeadingZeros(b[bLength - 1]); + int divisorShift = TBitLevel.numberOfLeadingZeros(b[bLength - 1]); if (divisorShift != 0) { TBitLevel.shiftLeft(normB, b, 0, divisorShift); TBitLevel.shiftLeft(normA, a, 0, divisorShift); @@ -120,7 +120,7 @@ static int[] divide(int[] quot, int quotLength, int[] a, int aLength, int[] b, i * this ensures that rightHand will overflow unsigned * long in the next step */ - if (TBigDecimal.numberOfLeadingZeros((int) (longR >>> 32)) < 32) { + if (TBitLevel.numberOfLeadingZeros((int) (longR >>> 32)) < 32) { rOverflowed = true; } else { rem = (int) longR; @@ -494,97 +494,37 @@ static long gcdBinary(long op1, long op2) { return (op2 << pow2Count); } - /// Calculates a.modInverse(p) Based on: Savas, E; Koc, C "The Montgomery - /// Modular Inverse - Revised" - static TBigInteger modInverseMontgomery(TBigInteger a, TBigInteger p) { - + /// Calculates `a.modInverse(p)` for `a` in `[1, p - 1]` with the extended + /// Euclidean algorithm. + /// + /// This replaced the Harmony Montgomery inverse (Savas and Koc) and Hars' + /// shifting inverse, both of which answered wrong values for ordinary + /// 256-bit inputs -- the P-256 group order among them -- because their + /// in-place shifts outgrew the fixed digit arrays they were given. + /// `BigIntegerModInverseTest` holds this against `java.math.BigInteger`. + static TBigInteger modInverseEuclid(TBigInteger a, TBigInteger p) { if (a.sign == 0) { - // ZERO hasn't inverse throw new ArithmeticException("BigInteger not invertible"); } - - if (!p.testBit(0)) { - // montgomery inverse require even modulo - return modInverseHars(a, p); - } - - int m = p.numberLength * 32; - // PRE: a \in [1, p - 1] - TBigInteger u; - TBigInteger v; - TBigInteger r; - TBigInteger s; - u = p.copy(); // make copy to use inplace method - v = a.copy(); - int max = Math.max(v.numberLength, u.numberLength); - r = new TBigInteger(1, 1, new int[max + 1]); - s = new TBigInteger(1, 1, new int[max + 1]); - s.digits[0] = 1; - // s == 1 && v == 0 - - int k = 0; - - int lsbu = u.getLowestSetBit(); - int lsbv = v.getLowestSetBit(); - int toShift; - - if (lsbu > lsbv) { - TBitLevel.inplaceShiftRight(u, lsbu); - TBitLevel.inplaceShiftRight(v, lsbv); - TBitLevel.inplaceShiftLeft(r, lsbv); - k += lsbu - lsbv; - } else { - TBitLevel.inplaceShiftRight(u, lsbu); - TBitLevel.inplaceShiftRight(v, lsbv); - TBitLevel.inplaceShiftLeft(s, lsbu); - k += lsbv - lsbu; - } - - r.sign = 1; - while (v.signum() > 0) { - // INV v >= 0, u >= 0, v odd, u odd (except last iteration when v is - // even (0)) - - while (u.compareTo(v) > TBigInteger.EQUALS) { - TElementary.inplaceSubtract(u, v); - toShift = u.getLowestSetBit(); - TBitLevel.inplaceShiftRight(u, toShift); - TElementary.inplaceAdd(r, s); - TBitLevel.inplaceShiftLeft(s, toShift); - k += toShift; - } - - while (u.compareTo(v) <= TBigInteger.EQUALS) { - TElementary.inplaceSubtract(v, u); - if (v.signum() == 0) { - break; - } - toShift = v.getLowestSetBit(); - TBitLevel.inplaceShiftRight(v, toShift); - TElementary.inplaceAdd(s, r); - TBitLevel.inplaceShiftLeft(r, toShift); - k += toShift; - } - } - if (!u.isOne()) { + TBigInteger oldR = a; + TBigInteger r = p; + TBigInteger oldS = TBigInteger.ONE; + TBigInteger s = TBigInteger.ZERO; + while (r.sign != 0) { + TBigInteger[] qr = oldR.divideAndRemainder(r); + oldR = r; + r = qr[1]; + TBigInteger nextS = oldS.subtract(qr[0].multiply(s)); + oldS = s; + s = nextS; + } + if (!oldR.isOne()) { throw new ArithmeticException("BigInteger not invertible."); } - if (r.compareTo(p) >= TBigInteger.EQUALS) { - TElementary.inplaceSubtract(r, p); + if (oldS.sign < 0) { + oldS = oldS.add(p); } - - r = p.subtract(r); - - // Have pair: ((BigInteger)r, (Integer)k) where r == a^(-1) * 2^k mod - // (module) - int n1 = calcN(p); - if (k > m) { - r = monPro(r, TBigInteger.ONE, p, n1); - k = k - m; - } - - r = monPro(r, TBigInteger.getPowerOfTwo(m - k), p, n1); - return r; + return oldS; } /// Calculate the first digit of the inverse @@ -614,82 +554,6 @@ static TBigInteger squareAndMultiply(TBigInteger x2, TBigInteger a2, TBigInteger return res; } - /// Implements the "Shifting Euclidean modular inverse algorithm". "Laszlo - /// Hars - Modular Inverse Algorithms Without Multiplications for - /// Cryptographic Applications" - /// - /// #### Parameters - /// - /// - `a`: a positive number - /// - /// - `m`: a positive modulus - /// - /// #### See also - /// - /// - TBigInteger#modInverse(TBigInteger) - static TBigInteger modInverseHars(TBigInteger a, TBigInteger m) { - // PRE: (a > 0) and (m > 0) - TBigInteger u; - TBigInteger v; - TBigInteger r; - TBigInteger s; - TBigInteger temp; - // u = MAX(a,m), v = MIN(a,m) - if (a.compareTo(m) == TBigInteger.LESS) { - u = m; - v = a; - r = TBigInteger.ZERO; - s = TBigInteger.ONE; - } else { - v = m; - u = a; - s = TBigInteger.ZERO; - r = TBigInteger.ONE; - } - int uLen = u.bitLength(); - int vLen = v.bitLength(); - int f = uLen - vLen; - - while (vLen > 1) { - if (u.sign == v.sign) { - u = u.subtract(v.shiftLeft(f)); - r = r.subtract(s.shiftLeft(f)); - } else { - u = u.add(v.shiftLeft(f)); - r = r.add(s.shiftLeft(f)); - } - uLen = u.abs().bitLength(); - vLen = v.abs().bitLength(); - f = uLen - vLen; - if (f < 0) { - // SWAP(u,v) - temp = u; - u = v; - v = temp; - // SWAP(r,s) - temp = r; - r = s; - s = temp; - - f = -f; - vLen = uLen; - } - } - if (v.sign == 0) { - return TBigInteger.ZERO; - } - if (v.sign < 0) { - s = s.negate(); - } - if (s.compareTo(m) == TBigInteger.GREATER) { - return s.subtract(m); - } - if (s.sign < 0) { - return s.add(m); - } - return s; // a^(-1) mod m - } - /* * Implements the Montgomery modular exponentiation based in The sliding * windows algorithm and the MongomeryReduction. diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 94bd8620cbd..57761a6fa84 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -10912,9 +10912,17 @@ public void run() { // somewhere and tells that receiver it succeeded. clip = ClipData.newPlainText("Codename One", ""); } + markClip(clip, (ClipboardContent) obj, sdk); + takeClipOwnership((ClipboardContent) obj, clip, sdk); } else { // Nothing of ours is staged for a plain text clip. clip = ClipData.newPlainText("Codename One", obj.toString()); + // A later copy of ours replaces whatever a pending clear was waiting for. + synchronized (STAGED_CLIP_FILES) { + clipOwner = null; + clipOwnerToken = null; + pendingClipClear = null; + } } watchPrimaryClip(clipboard); // Pinned for the length of the call, held only if it returns. setPrimaryClip @@ -10942,6 +10950,254 @@ public void run() { }); } + /// The extra API 33 reads to keep a clip's text out of the copy preview and the keyboard's + /// suggestions. Spelled out because the platform jar this port compiles against predates the + /// constant; API 24 is the floor of `ClipDescription.setExtras`. + private static final String EXTRA_IS_SENSITIVE = "android.content.extra.IS_SENSITIVE"; + + /// Ours, so a scheduled clear can recognise its own clip without reading the clip itself + /// (reading it shows the "pasted from" notice on Android 12 and newer). + private static final String EXTRA_CN1_CLEAR_AFTER = "com.codename1.clipboard.clearAfter"; + + /// Ours, naming the one copy a clip came from, so a clear can tell its own clip from a later + /// copy of ours or another application's when the description is readable. + private static final String EXTRA_CN1_CLIP_TOKEN = "com.codename1.clipboard.token"; + + /// The content whose clip is on the clipboard now, as far as this process knows, and the + /// token stamped into that clip. Ownership is per copy: a scheduled clear only acts when its + /// own content is still the owner, so a second copy is never wiped by the first one's timer. + /// + /// The description cannot be the evidence on its own: from Android 10 a background + /// application reads null from `getPrimaryClipDescription`, which is exactly when a timed + /// clear fires. So ownership is kept here, dropped by the clip-changed listener (delivered + /// only while focused) and re-checked against the description on resume and whenever the + /// description is readable. + private static ClipboardContent clipOwner; + private static String clipOwnerToken; + private static long clipTokenCounter; + + /// A timed clear that fired while the clipboard could not be read (a background + /// application on Android 10 and later reads a null description), so nothing proved the + /// clip was still ours. Clearing anyway could wipe a clip the user copied from another + /// application in the meantime, so the clear waits here and runs on the next resume once + /// the token is readable and still ours. + /// + /// The tradeoff is deliberate: a sensitive clip can outlive its timer for as long as the + /// application stays in the background. Destroying another application's clip is the + /// worse failure, and the sensitive flag still keeps the value out of clipboard previews. + private static ClipboardContent pendingClipClear; + + private static final int CLIP_OURS = 0; + private static final int CLIP_NOT_OURS = 1; + private static final int CLIP_UNKNOWN = 2; + + private static void markClip(ClipData clip, ClipboardContent content, int sdk) { + if (sdk < 24 || (!content.isSensitive() && content.getClearAfter() <= 0)) { + return; + } + PersistableBundle extras = new PersistableBundle(); + if (content.isSensitive()) { + extras.putBoolean(EXTRA_IS_SENSITIVE, true); + } + if (content.getClearAfter() > 0) { + extras.putInt(EXTRA_CN1_CLEAR_AFTER, content.getClearAfter()); + } + clip.getDescription().setExtras(extras); + } + + /// Records `content` as the owner of the clip about to be published. Below API 24 there is + /// nowhere to stamp a token and no listener to trust, so nothing is owned and nothing is + /// cleared there. + private static void takeClipOwnership(ClipboardContent content, ClipData clip, int sdk) { + synchronized (STAGED_CLIP_FILES) { + clipOwner = null; + clipOwnerToken = null; + pendingClipClear = null; + if (sdk < 24 || content.getClearAfter() <= 0) { + return; + } + clipTokenCounter++; + String token = System.currentTimeMillis() + "-" + clipTokenCounter; + PersistableBundle extras = clip.getDescription().getExtras(); + if (extras == null) { + extras = new PersistableBundle(); + } + extras.putString(EXTRA_CN1_CLIP_TOKEN, token); + clip.getDescription().setExtras(extras); + clipOwner = content; + clipOwnerToken = token; + } + } + + /// Drops ownership when the clipboard, read while it can be, holds something other than + /// the owned clip. A null description says nothing (background, or no clip yet) and keeps it. + /// + /// #### Returns + /// + /// `CLIP_OURS` when the description carries the owned token, `CLIP_NOT_OURS` when nothing + /// is owned or another clip replaced it, `CLIP_UNKNOWN` when the description is unreadable + private static int verifyClipOwnership(android.content.ClipboardManager clipboard) { + synchronized (STAGED_CLIP_FILES) { + if (clipOwnerToken == null) { + return CLIP_NOT_OURS; + } + } + android.content.ClipDescription description; + try { + description = clipboard.getPrimaryClipDescription(); + } catch (Throwable t) { + return CLIP_UNKNOWN; + } + if (description == null) { + return CLIP_UNKNOWN; + } + PersistableBundle extras = description.getExtras(); + String token = extras == null ? null : extras.getString(EXTRA_CN1_CLIP_TOKEN); + synchronized (STAGED_CLIP_FILES) { + if (clipOwnerToken != null && clipOwnerToken.equals(token)) { + return CLIP_OURS; + } + clipOwner = null; + clipOwnerToken = null; + pendingClipClear = null; + return CLIP_NOT_OURS; + } + } + + /// Called when the activity resumes: the listener missed every change made while this + /// application was in the background, and now the description is readable again. A clear + /// deferred while in the background runs here, once the clip is proven still ours. + static void verifyClipOwnershipOnResume(Context context) { + if (android.os.Build.VERSION.SDK_INT < 24 || context == null) { + return; + } + synchronized (STAGED_CLIP_FILES) { + // The listener is delivered only while focused, so a change counted while in the + // background (ours or a failed publish) may never have been consumed. A stale count + // would swallow the next foreign copy and keep a clip that is no longer ours owned. + expectedClipChanges = 0; + } + try { + android.content.ClipboardManager clipboard = (android.content.ClipboardManager) + context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard == null) { + return; + } + ClipboardContent pending; + synchronized (STAGED_CLIP_FILES) { + pending = pendingClipClear; + } + if (verifyClipOwnership(clipboard) == CLIP_OURS && pending != null) { + clearOwnedClip(clipboard, pending); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + /// Called when the activity gains window focus. On Android 10 and later the clipboard + /// can be read only by the focused application, and focus arrives after onResume, so the + /// check made there can answer "unknown" and leave a deferred clear pending. This retries + /// it once the description is readable. The change counter is not reset here: focus can + /// return without a resume (a dialog or the notification shade), while the listener kept + /// running. + static void verifyClipOwnershipOnFocus(Context context) { + if (android.os.Build.VERSION.SDK_INT < 24 || context == null) { + return; + } + retryPendingClipClear(context); + } + + private static void retryPendingClipClear(Context context) { + try { + ClipboardContent pending; + synchronized (STAGED_CLIP_FILES) { + pending = pendingClipClear; + } + if (pending == null) { + return; + } + android.content.ClipboardManager clipboard = (android.content.ClipboardManager) + context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard != null && verifyClipOwnership(clipboard) == CLIP_OURS) { + clearOwnedClip(clipboard, pending); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + /// Clears the primary clip if `content` still owns it. Only called once the token was read + /// back and matched, which also means the description was readable and the clip-changed + /// callback for this clear will be delivered. + private static void clearOwnedClip(android.content.ClipboardManager clipboard, ClipboardContent content) { + synchronized (STAGED_CLIP_FILES) { + if (content == null || clipOwner != content) { + // A later copy (ours or another application's) replaced it. + pendingClipClear = null; + return; + } + clipOwner = null; + clipOwnerToken = null; + pendingClipClear = null; + // Our own replacement, not another application's copy. Counted only while the + // listener is installed, since otherwise no callback will consume it. + if (clipboardWatched) { + expectedClipChanges++; + } + } + boolean cleared = false; + if (android.os.Build.VERSION.SDK_INT >= 28) { + try { + // Reflective: the platform jar this port compiles against predates + // clearPrimaryClip (API 28). + clipboard.getClass().getMethod("clearPrimaryClip").invoke(clipboard); + cleared = true; + } catch (Throwable t) { + cleared = false; + } + } + if (!cleared) { + clipboard.setPrimaryClip(ClipData.newPlainText("", "")); + } + } + + @Override + public void clearClipboardContent(final ClipboardContent content) { + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + if (android.os.Build.VERSION.SDK_INT < 24) { + // No token and no ownership below API 24, so nothing proves the clip is + // still ours; clearing blind could wipe another application's copy. + return; + } + android.content.ClipboardManager clipboard = (android.content.ClipboardManager) + getActivity().getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard == null) { + return; + } + int state = verifyClipOwnership(clipboard); + if (state == CLIP_UNKNOWN) { + // In the background the clip cannot be read, so it may already be another + // application's. Defer to the next resume (see pendingClipClear). + synchronized (STAGED_CLIP_FILES) { + if (content != null && clipOwner == content) { + pendingClipClear = content; + } + } + return; + } + if (state == CLIP_OURS) { + clearOwnedClip(clipboard, content); + } + } + }); + } + /// Builds the Android clip that publishes a `ClipboardContent`, for a clipboard copy and /// for a native drag alike -- both hand another application the same thing, so both go /// through the same conversion, including the file provider URIs that let the receiving @@ -12171,7 +12427,12 @@ public void onPrimaryClipChanged() { } } // A clip somebody else published replaced ours, so what ours was carrying - // is nobody's to paste any more. + // is nobody's to paste any more, and a pending clear must not wipe theirs. + synchronized (STAGED_CLIP_FILES) { + clipOwner = null; + clipOwnerToken = null; + pendingClipClear = null; + } clipboardHolds(0); } }); diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java index 2d9dca7b608..c164a2f1ac3 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java @@ -1546,8 +1546,19 @@ private boolean createKey() { // Skip setUserAuthenticationRequired on API 33+ per // FingerprintScanner #8; the BiometricPrompt still authenticates // the user, but the keystore no longer ties the key lifetime to - // biometric enrolment (which caused recovery failures). - if (Build.VERSION.SDK_INT < 33) { + // biometric enrolment (which caused recovery failures). That stays + // the default so installed apps keep working on update. An app + // that opts in with setStrictBiometricBinding gets a key bound on + // every level and revoked by a new enrolment (API 24+); the prompt + // carries the cipher as its CryptoObject, which a per-use bound key + // needs. Only a key created here is affected: an existing key is + // never deleted for being unbound, which would wipe its entries. + if (isStrictBiometricBinding()) { + b.setUserAuthenticationRequired(true); + if (Build.VERSION.SDK_INT >= 24) { + b.setInvalidatedByBiometricEnrollment(true); + } + } else if (Build.VERSION.SDK_INT < 33) { b.setUserAuthenticationRequired(true); } keyGenerator.init(b.build()); diff --git a/Ports/Android/src/com/codename1/impl/android/CodenameOneActivity.java b/Ports/Android/src/com/codename1/impl/android/CodenameOneActivity.java index fb9530b303d..fe93e881694 100644 --- a/Ports/Android/src/com/codename1/impl/android/CodenameOneActivity.java +++ b/Ports/Android/src/com/codename1/impl/android/CodenameOneActivity.java @@ -133,11 +133,20 @@ public void onConfigurationChanged(android.content.res.Configuration config) { com.codename1.ui.Display.getInstance().nativeThemeSettingsChanged(); } + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) { + AndroidImplementation.verifyClipOwnershipOnFocus(this); + } + } + @Override protected void onResume() { super.onResume(); AndroidImplementation.setActivity(this); AndroidNativeUtil.onResume(); + AndroidImplementation.verifyClipOwnershipOnResume(this); com.codename1.ui.Display.getInstance().nativeThemeSettingsChanged(); if (isBillingEnabled() && getBillingSupport() != null) { billingSupport.consumeAndAcknowlegePurchases(); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index 78cceabd0b3..2ff394fb42a 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -1410,6 +1410,7 @@ public void run() { currentEditingField = ta; currentInputField = el; isEditing = true; + editingSessionCount++; // Browser focus bypasses editStringImpl(), which normally // registers the session for form-level editing APIs. setFocusedEditingText(ta); @@ -1870,6 +1871,7 @@ public void handleEvent(Event evt) { public void run() { donePressed = false; isEditing = true; + editingSessionCount++; inputEl.getStyle().setProperty("color", HTML5Graphics.color(ta.getStyle().getFgColor())); Font f = ta.getSelectedStyle().getFont(); @@ -2780,6 +2782,15 @@ public void handleEvent(Event evt) { // clears the flag after Display.pointerPressed returns, then // runs any release that onMouseUp deferred while waiting. final PressSlot press = beginPressInFlight(); + // The edit this press ends is the one open NOW, read before any JSO call + // yields. The runnable below is queued on the EDT behind those yields, and + // by the time it runs, this same click's release may already have opened + // the field it landed on -- it used to close that session again at once. + // With text selection on the form (which adds work to every press) it did + // so on nearly every click: the field blurred under the first keystrokes, + // the next session's commit replayed a stale value over the model, and + // typed characters were lost. + final int sessionAtPress = isEditing ? editingSessionCount : -1; if (nativeEventListener != null) { CancelableEvent cevt = (CancelableEvent)evt; nativeEventListener.handleEvent(evt); @@ -2825,7 +2836,7 @@ public void handleEvent(Event evt) { callSerially(new Runnable() { public void run() { - if (isEditing){ + if (isEditing && editingSessionCount == sessionAtPress){ finishTextEditing(); } } @@ -5219,6 +5230,8 @@ public void init(Object m) { if (JavaScriptInputCoordinator.shouldInstallKeyboard(isPhoneOrTablet_())) { HTML5Keyboard.install(); } + // Passkeys through the page's navigator.credentials. + HTML5WebAuthn.install(); // Set the locale installBeforeUnload(); @@ -6953,6 +6966,10 @@ private String generateUniqueImagePath(FileSystemStorage fs, String baseName, St private native static void scroll_(); private boolean isEditing=false; + /// Counts editing sessions, so that work queued for one session cannot end the next. + /// See onMouseDown: its "a press elsewhere ends the edit" runnable was queued before + /// the same click's release had opened a new session, and ran after it. + private int editingSessionCount; /** * Text field that is used for editing. @@ -7714,6 +7731,7 @@ public void editString(final Component cmp, int maxSize, int constraint, final S } text = origText; isEditing=true; + editingSessionCount++; // This Hack is specifically to work around an issue on iOS Safari // where the "touch" event circumvents the editing process, causing @@ -8022,6 +8040,7 @@ public void run() { } } + final HTMLInputElement focusedInputEl = inputEl; final FocusListener focusListener = new FocusListener() { @Override @@ -8031,7 +8050,15 @@ public void focusGained(Component cmpnt) { @Override public void focusLost(Component cmpnt) { - + // Commit the value now, not only in the completion callback below. Focus + // moves inside Form.pointerPressed, so this runs before the release of the + // same click; the callback is queued, and a button whose action reads the + // field (a sign-in form's "Sign in") could fire first and read the model + // as it was before anything was typed. + String value = focusedInputEl.getValue(); + if (value != null && !value.equals(ta.getText())) { + ta.setText(value); + } finishTextEditing(); } diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5WebAuthn.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5WebAuthn.java new file mode 100644 index 00000000000..7dc2fd1671c --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5WebAuthn.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.html5; + +import com.codename1.io.webauthn.WebAuthnClient; +import com.codename1.io.webauthn.WebAuthnException; +import com.codename1.io.webauthn.WebAuthnNative; + +import java.io.UnsupportedEncodingException; + +/// Passkeys in a browser: [WebAuthnClient]'s ceremonies run through the page's own +/// `navigator.credentials`, so an application signs in with a passkey in its web build the way it +/// does on a phone, against the same server. +/// +/// The translated application runs in a worker, which has no `navigator.credentials`; each +/// ceremony is forwarded to the `__cn1_webauthn__` host bridge in `browser_bridge.js`, which +/// runs it on the page and answers with the credential as WebAuthn JSON. The relying party is +/// the page's own origin, so a server accepts these only when it lists that origin. +/// +/// Installed by [HTML5Implementation#init(Object)]. +final class HTML5WebAuthn implements WebAuthnNative { + /// The answer's first byte: the credential follows as UTF-8 JSON. + private static final int OK = 0; + /// The user dismissed the browser's sheet, or the browser refused without saying more. + private static final int CANCELLED = 1; + /// A failure; `name:message` follows, the name a DOMException name. + private static final int FAILED = 2; + + static void install() { + WebAuthnClient.setProvider(new HTML5WebAuthn()); + } + + @Override + public boolean isSupported() { + return nativeSupported(); + } + + @Override + public String createPasskey(String creationOptionsJson) throws WebAuthnException { + return answer(nativeCeremony("create", creationOptionsJson)); + } + + @Override + public String getPasskey(String requestOptionsJson) throws WebAuthnException { + return answer(nativeCeremony("get", requestOptionsJson)); + } + + /// The credential JSON, null for a dismissed sheet (the caller's NotAllowedError), or the + /// failure as a typed exception. + private static String answer(byte[] reply) throws WebAuthnException { + if (reply == null || reply.length == 0) { + throw new WebAuthnException(WebAuthnException.TRANSPORT_ERROR, + "The browser did not answer the passkey request"); + } + String text; + try { + text = new String(reply, 1, reply.length - 1, "UTF-8"); + } catch (UnsupportedEncodingException impossible) { + throw new WebAuthnException(WebAuthnException.TRANSPORT_ERROR, "UTF-8 is missing"); + } + if (reply[0] == OK) { + return text; + } + if (reply[0] == CANCELLED) { + return null; + } + int colon = text.indexOf(':'); + String code = colon > 0 ? text.substring(0, colon) : WebAuthnException.TRANSPORT_ERROR; + String message = colon > 0 ? text.substring(colon + 1) : text; + throw new WebAuthnException(code, message.length() == 0 ? "Passkey request failed" : message); + } + + // Bound in Ports/JavaScriptPort/src/main/webapp/port.js, forwarding to the __cn1_webauthn__ + // host bridge in vm/ByteCodeTranslator/src/javascript/browser_bridge.js. + + /// Whether the page has `navigator.credentials` and `PublicKeyCredential`. + static native boolean nativeSupported(); + + /// Runs `navigator.credentials.` (`create` or `get`) with the options JSON. The first + /// byte of the answer is [#OK], [#CANCELLED] or [#FAILED]. + static native byte[] nativeCeremony(String op, String optionsJson); +} diff --git a/Ports/JavaScriptPort/src/main/webapp/port.js b/Ports/JavaScriptPort/src/main/webapp/port.js index 0486cb28615..182606143d2 100644 --- a/Ports/JavaScriptPort/src/main/webapp/port.js +++ b/Ports/JavaScriptPort/src/main/webapp/port.js @@ -1549,6 +1549,43 @@ bindNative([ })); }); +// -------------------------------------------------------------------------- +// com.codename1.impl.html5.HTML5WebAuthn -- WebAuthnClient's passkey ceremonies, +// run by the page (the worker has no navigator.credentials) through the +// __cn1_webauthn__ host bridge in browser_bridge.js. An older host bundle +// without the handler rejects the call; that is "not supported", not a crash. +// -------------------------------------------------------------------------- + +bindNative([ + "cn1_com_codename1_impl_html5_HTML5WebAuthn_nativeSupported_R_boolean" +], function*() { + if (typeof jvm.invokeHostNative !== "function") { + return 0; + } + try { + return (yield jvm.invokeHostNative("__cn1_webauthn__", [{ op: "supported" }])) ? 1 : 0; + } catch (err) { + return 0; + } +}); + +bindNative([ + "cn1_com_codename1_impl_html5_HTML5WebAuthn_nativeCeremony_java_lang_String_java_lang_String_R_byte_1ARRAY" +], function*(op, options) { + let reply; + try { + reply = yield jvm.invokeHostNative("__cn1_webauthn__", [{ + op: jvm.toNativeString(op), + options: jvm.toNativeString(options) + }]); + } catch (err) { + reply = { status: 2, text: "transport_error:" + String(err && err.message || err) }; + } + const status = reply && typeof reply.status === "number" ? reply.status : 2; + const bytes = Array.from(new TextEncoder().encode(String(reply && reply.text || ""))); + return cn1VaultJavaBytes([status].concat(bytes)); +}); + // PBKDF2 through Web Crypto. The portable fallback in KdfProfile is the same // algorithm in pure Java and produces identical bytes, and at the default six // hundred thousand iterations it is minutes of a translated worker's time diff --git a/Ports/JavaScriptPort/tests/fixtures/JavaScriptSelectionApp.java b/Ports/JavaScriptPort/tests/fixtures/JavaScriptSelectionApp.java index 8f13de53535..4a153e14f90 100644 --- a/Ports/JavaScriptPort/tests/fixtures/JavaScriptSelectionApp.java +++ b/Ports/JavaScriptPort/tests/fixtures/JavaScriptSelectionApp.java @@ -207,6 +207,22 @@ protected void paintGlass(Graphics g) { if (query.indexOf("handoff=true") >= 0) second.startEditingAsync(); })); form.addAll(new Button("Initial focus"), first, second, check, stop, status); + } else if (query.indexOf("review=selectionsignin") >= 0) { + // Text selection on for the whole form, as a screen with a copyable value has it, + // and fields that use the legacy editor. Typing and then pressing a button at once + // lost the typed text: the press that opened a field closed it again, and the + // button's action read the model before the field had committed. + form.getTextSelection().setEnabled(true); + TextArea copyable = new TextArea("Copyable reference ABC-123"); + copyable.setEditable(false); + copyable.setTextSelectionEnabled(true); + final TextField email = new TextField("", "E-mail", 20, TextArea.EMAILADDR); + email.setName("signinEmail"); + final TextField password = new TextField("", "Password", 20, TextArea.PASSWORD); + password.setName("signinPassword"); + Button submit = new Button("Submit sign-in"); + submit.addActionListener(e -> status.setText("Submitted " + email.getText() + "|" + password.getText())); + form.addAll(copyable, email, password, submit, status); } else if (query.indexOf("review=multitouch") >= 0) { Label label = new Label("Pinch promoted label"); TextArea area = new TextArea("Pinch native editor", 2, 24); diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 709e5727b57..db360e2242e 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -974,6 +974,33 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_getClipboardString___R_java_lang_St #endif } +#if !TARGET_OS_OSX && !TARGET_OS_WATCH && !TARGET_OS_TV +/// The options the next setClipboardContent publishes with, armed by restrictClipboard; nil +/// when that publish is an ordinary one. They have to go out on the SAME setItems call as the +/// content: publishing first and restricting after left a window in which Universal Clipboard +/// could already have synced a sensitive clip to the user's other devices. +static NSDictionary* cn1PendingClipboardOptions = nil; +#endif + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_restrictClipboard___int_R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT expireSeconds) { +#if !TARGET_OS_OSX && !TARGET_OS_WATCH && !TARGET_OS_TV + POOL_BEGIN(); + NSMutableDictionary *options = [NSMutableDictionary dictionary]; + options[UIPasteboardOptionLocalOnly] = @YES; + if (expireSeconds > 0) { + options[UIPasteboardOptionExpirationDate] = [NSDate dateWithTimeIntervalSinceNow:expireSeconds]; + } +#if !__has_feature(objc_arc) + [cn1PendingClipboardOptions release]; +#endif + cn1PendingClipboardOptions = [options copy]; + POOL_END(); + return expireSeconds > 0 ? JAVA_TRUE : JAVA_FALSE; +#else + return JAVA_FALSE; +#endif +} + void com_codename1_impl_ios_IOSNative_setClipboardString___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT str) { #if TARGET_OS_OSX POOL_BEGIN(); @@ -1416,7 +1443,16 @@ void com_codename1_impl_ios_IOSNative_setClipboardContent___java_lang_String_jav // Whatever the alternatives were not able to ride on, which is the whole payload when // the content named no files at all. if ([item count] > 0) [items addObject:item]; - [UIPasteboard generalPasteboard].items = items; + if (cn1PendingClipboardOptions != nil) { + // Local only and expiring from the first moment the clip exists; see restrictClipboard. + [[UIPasteboard generalPasteboard] setItems:items options:cn1PendingClipboardOptions]; +#if !__has_feature(objc_arc) + [cn1PendingClipboardOptions release]; +#endif + cn1PendingClipboardOptions = nil; + } else { + [UIPasteboard generalPasteboard].items = items; + } [cn1PendingClipboardExtras removeAllObjects]; POOL_END(); #endif diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index f7680d13aac..e54b513fbb9 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -10631,6 +10631,9 @@ public static void nativeDragPayloadReleasedCallback(int sessionId) { @Override public void copyToClipboard(Object obj) { + // Whatever is copied now replaces the last restricted copy, expiry and all: a plain + // copy has no expiry, so a clear scheduled for it must not be skipped as if it had. + clipboardExpires = false; if(obj instanceof com.codename1.ui.ClipboardContent) { com.codename1.ui.ClipboardContent content = (com.codename1.ui.ClipboardContent)obj; // Every representation, now, including any registered as a provider. The pasteboard @@ -10664,6 +10667,14 @@ public void copyToClipboard(Object obj) { nativeInstance.addClipboardRepresentation(offered[iter], bytes); } } + // Armed BEFORE the publish so the clip goes out local only and expiring on the + // very same pasteboard write; restricting it afterwards left a window in which + // Universal Clipboard could sync a sensitive value to the user's other devices. + if(content.isSensitive() || content.getClearAfter() > 0) { + // Rounded up, so a short time still expires rather than meaning "never". + clipboardExpires = nativeInstance.restrictClipboard(content.getClearAfter() <= 0 ? 0 + : (content.getClearAfter() + 999) / 1000); + } nativeInstance.setClipboardContent( clipboardText(content, com.codename1.ui.ClipboardContent.MIME_TEXT), clipboardText(content, com.codename1.ui.ClipboardContent.MIME_HTML), @@ -10684,6 +10695,19 @@ public void copyToClipboard(Object obj) { super.copyToClipboard(obj); } + @Override + public void clearClipboardContent(com.codename1.ui.ClipboardContent content) { + // On iOS the pasteboard expires it by itself (restrictClipboard), even if this + // application is suspended by then; clearing here as well could only wipe something + // copied since. macOS has no expiry, so there the default clear runs. + if(!clipboardExpires) { + super.clearClipboardContent(content); + } + } + + /// Whether the last restricted copy was given a pasteboard expiry date. + private boolean clipboardExpires; + /// True when setClipboardContent already has an argument for this type, so it must not be /// published a second time beside itself. private static boolean isNamedClipboardType(String mime, String publishedImage) { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index cf4b1c79484..05c191d083e 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -377,6 +377,11 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre native void addClipboardRepresentation(String mimeType, byte[] value); native void setClipboardContent(String plain, String html, String rtf, String markdown, String asciidoc, byte[] image, String fileUris); native String getClipboardContent(String mimeType); + /// Makes the NEXT setClipboardContent publish local only (no Universal Clipboard) and, when + /// `expireSeconds` is above zero, set to expire after that many seconds; the options go out + /// on the same pasteboard write as the content. False where the pasteboard has no such + /// options (macOS), so the content is published as it is. + native boolean restrictClipboard(int expireSeconds); /// How many representations the system pasteboard is offering, so they can be read by /// index rather than joined into one string -- see addNativeDragFiles for what a diff --git a/docs/demos/common/src/main/snippets/developer-guide/bastion.sh b/docs/demos/common/src/main/snippets/developer-guide/bastion.sh new file mode 100644 index 00000000000..5dc7f1ed64b --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/bastion.sh @@ -0,0 +1,38 @@ +// Snippets of the Bastion chapters of the developer guide. Edit them here, not inline. + +// tag::bastion-sh-impl-1[] +cd scripts/bastion +CN1_PROFILE=dev backend/server.sh run +// end::bastion-sh-impl-1[] + +// tag::bastion-sh-impl-2[] +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +// end::bastion-sh-impl-2[] + +// tag::bastion-sh-impl-3[] +cd scripts/bastion +# The server's tests on the JVM, then compiled to a native binary +mvn -Dcodename1.platform=backend -pl shared,backend test -Dcn1.backend.compiledTests=true +# The same tests on PostgreSQL or MySQL +CN1_TEST_DATABASE_URL="postgres://user:password@localhost:5432/db?sslmode=disable" \ + ../ci/sample-server-tests-on-database.sh . +# The app's own tests, which run headless as part of the build +mvn -Dcodename1.platform=javase -Djava.awt.headless=true package +# A running server, driven over HTTP the way the app drives it +./run-e2e.sh +./run-e2e.sh --native +# The app in a headless browser, served by the server +./run-web-e2e.sh +# The same, then every screen in the user's guide, into docs/developer-guide/img +./run-web-e2e.sh --guide +// end::bastion-sh-impl-3[] + +// tag::bastion-sh-users-1[] +cd scripts/bastion +CN1_PROFILE=dev backend/server.sh run +// end::bastion-sh-users-1[] + +// tag::bastion-sh-users-2[] +cd scripts/bastion +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +// end::bastion-sh-users-2[] diff --git a/docs/developer-guide/Bastion-Implementors-Guide.asciidoc b/docs/developer-guide/Bastion-Implementors-Guide.asciidoc new file mode 100644 index 00000000000..f96cecad95a --- /dev/null +++ b/docs/developer-guide/Bastion-Implementors-Guide.asciidoc @@ -0,0 +1,406 @@ +[[bastion-implementors-guide]] +== Bastion: Building on the sample + +This chapter is for the developer who takes `scripts/bastion` as the start of a real banking or exchange app. It covers how the sample is put together, how sign-in, a signed payment, a trade and the audit chain work, every security feature with the file it lives in, the settings and the endpoints, the threats it's built against, what only a cloud build turns on, and the work that stands between the sample and a product. + +=== Running it + +The server runs on the development profile with an in-memory database: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/bastion.sh[tag=bastion-sh-impl-1,indent=0] +---- + +The app runs in the simulator from another terminal: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/bastion.sh[tag=bastion-sh-impl-2,indent=0] +---- + +`backend/server.sh run --native` runs the same server compiled to a native binary, and `--web` also builds the app for the browser and serves it from the server at `/`. + +=== The modules + +image::img/bastion-architecture.svg["The common module with the app's screens and security classes, the shared module with the contracts, and the backend module with the server's packages over a database",scaledwidth=95%] + +[cols="1,3",options="header"] +|=== +|Module |What it holds +|`shared` |The transfer objects and the `@RestClient` contracts (`BankingApi`, `ExchangeApi`, `AccountApi`, `AdminApi`), `SignedRequest`, the canonical form both sides sign and verify, and `Refusal`, the stable codes of the server's refusals. +|`backend` |The Codename One Backend server: the ledger, accounts and sign-in, money movement, the exchange, the audit chain, and the administrator's checks. +|`common` |The app: every screen, the network guard, the app key, the device key and request signer, the app lock, the screen protections and the Security Center. +|=== + +The project has the minimal layout, with no platform modules: `common` builds the simulator, the desktop app, Android, iOS and the browser. The server's classes live under `backend/src/main/java/com/codenameone/examples/bastion` and the app's under `common/src/main/java/com/codenameone/examples/bastion`; the paths in this chapter are relative to those. + +=== Signing in + +image::img/bastion-sign-in-flow.svg["A sequence between the app and the server: password, redirect to the code step, the code, the authorization request with a PKCE challenge, the code handed back, the token request with the verifier and the tokens; and the passkey alternative",scaledwidth=95%] + +The server is its own OAuth 2 authorization server (`SecurityConfig.java`). The app (`net/Session.java`) posts the password, answers the `/login/mfa` step when the account has an authenticator app, and then runs the authorization-code flow with PKCE. A passkey replaces the first three steps with a WebAuthn assertion, and the server asks for user verification on every one (`userVerification("required")`), so a passkey sign-in is always a fingerprint, a face or the device PIN as well as the key. + +The sign-in screen (`ui/SignInForm.java`) offers the strongest factor the device has first: a passkey, then the stored sign-in a biometric opens (the app key the lock screen releases, which also seals the device's payment key), then the password and code. The server doesn't leave that to the app. `account/SignInGuard.java` holds an account to the strongest factor it has enrolled: for an account with a passkey, `POST /login` is answered 403 with `"stronger":"passkey"` before the password is checked, unless the account has an authenticator app, and then the `/login/mfa` step accepts a recovery code and refuses a six-digit code with the same answer. The password and a recovery code are the recovery path for a lost passkey; each code works once, and the sign-in is in the audit log. An account with a passkey and no authenticator app is recovered by the bank removing the passkey after checking who is asking. Every refusal is audited as `SIGNIN_REFUSED`. The answer tells whoever knows the e-mail that the account has a passkey, the same trade the lockout makes. In a browser the app runs the same steps from the page, with the browser's own session cookie, and reads its code from `/signin/code` on the server it came from. + +Access tokens last five minutes and refresh tokens twelve hours, rotating on every use. Each access token carries an `amr` claim saying how the sign-in was made: `pwd`, `otp` and `mfa`, or `hwk` for a passkey. `money/Devices.java` reads it: a device key is registered only within ten minutes of a sign-in, and also needs the password unless the sign-in used a second factor or a passkey the authenticator verified the user for (`hwk` together with `user`); `hwk` alone proves only that the key was present. `account/SecondFactor.java` holds adding an authenticator app to the same rule, stricter: after a password-only sign-in both the start and the confirmation carry the password as well as being within those ten minutes, so a stolen token can't enroll the thief's app however fresh it is. + +=== A signed payment + +image::img/bastion-signed-request.svg["The app unseals its device key, builds the canonical form of the request and signs it; the server checks the token, the signature, freshness, the device policy, the step-up threshold, the limits and idempotency before the ledger entry",scaledwidth=95%] + +Every call that sends value away carries a `Bastion-Signature` header made by `security/RequestSigner.java`: + +---- +Bastion-Signature: v1;device=;ts=;nonce=;sig= +---- + +The signature is ECDSA P-256 with SHA-256 over `METHOD|target|hex(sha256(body))|idempotency-key|nonce|ts`, built by `SignedRequest` in `shared` so that both sides compute the same bytes. The app signs the exact JSON it sends. The `Idempotency-Key` is signed too (an empty field for a call without one): otherwise a captured request could be sent again under a fresh key and its fresh nonce, and the server would take it for a new payment. The server (`money/RequestSigning.java`) refuses an unknown device, a bad signature, a timestamp outside the window or a nonce it has seen, and commits the nonce in its own transaction before the money transaction starts, so a replay that races the original still loses. + +A refusal is answered as `(code|value|value) English text`. The code is one of the `Refusal` constants; the app looks it up as `error.` in its own bundle (`net/Net.java`), so the customer reads it in their language and a reworded English sentence never breaks a translation. `ui/DeviceSetup.java` reacts to the device codes: it offers to register a key and then sends the same request again, with a fresh nonce and the same idempotency key. + +=== The ledger + +Every amount is a `long` in the asset's smallest unit: cents for dollars and euros, satoshi for bitcoin. Ether is kept in units of 10^-9^ (gwei) rather than wei, so that a balance fits a `long`; a real ether product that needs wei precision has to widen the column and the arithmetic. + +`ledger/Ledger.java` is the only code that changes a balance. It posts a double-entry journal entry, whose postings sum to zero in every asset, in one transaction: + +* *Conditional updates.* A balance moves with one `UPDATE ... WHERE balance + :delta >= 0`, so two payments racing for the last dollar can't both succeed, and no balance is ever read, changed in Java, and written back. +* *Lock order.* The accounts an entry touches are updated in the order of their ids, so two entries over the same accounts can't deadlock each other. +* *Idempotency.* Every money request carries an `Idempotency-Key`. The key and a hash of the request are stored with the entry; a retry answers the first entry, and the same key with a different request is refused. +* *Rollback on any failure.* Every transaction is `@Transactional(rollbackFor = Exception.class)`. A checked exception would otherwise commit what was done before it. + +A trade is the richest entry the ledger makes: + +image::img/bastion-trade-entry.svg["The five postings of one trade: the buyer's hold pays dollars, the seller's checking account receives them less nothing, the fee account takes the fee, the seller's coin hold gives the coins and the buyer's wallet receives them",scaledwidth=95%] + +`exchange/Exchange.java` sets aside what an order could spend in a `HOLD` account when it's placed, and pays fills out of the holds. Each order keeps the exact running sum of price times quantity over its fills and rounds it once: up for what a buyer pays, down for what a seller gets, with the difference and the taker fee going to `SYS_FEES`. The market's row is locked before matching, so one market matches one order at a time. + +`ledger/IsolationCheck.java` refuses to start the server on MySQL at its default REPEATABLE READ isolation. The ledger, the audit chain and the order book each serialize their writers on one row and then read what the previous writer committed, and REPEATABLE READ answers those reads from an older snapshot. Run MySQL at READ COMMITTED (`transaction-isolation = READ-COMMITTED` in its configuration). PostgreSQL runs at READ COMMITTED by default, and SQLite serializes every writer. + +=== The accounting model + +The ledger stores one signed number per posting. Read as accounting, a positive amount is a credit and a negative one a debit, so an entry that sums to zero is an entry whose debits equal its credits. A customer's deposit is money the bank owes, so customer accounts are liabilities; the cash that came in is an asset. `books/Chart.java` maps every account kind to its place in the chart of accounts, in code, so no row can put a customer's deposit among the bank's assets: + +[cols="1,3,1,1,2",options="header"] +|=== +|Code |Account |Class |Normal balance |Kind +|1000 |Cash and reserves |Asset |Debit |`SYS_CASH` +|1100 |Digital assets in custody |Asset |Debit |`SYS_CHAIN` +|2000 |Customer deposits, checking |Liability |Credit |`CHECKING` +|2010 |Customer deposits, savings |Liability |Credit |`SAVINGS` +|2100 |Customer digital asset custody |Liability |Credit |`WALLET` +|2200 |Order escrow |Liability |Credit |`HOLD` +|3000 |Paid-in capital |Equity |Credit |`SYS_EQUITY` +|3100 |Retained earnings |Equity |Credit |`SYS_RETAINED` +|4000 |Fee and trading revenue |Revenue |Credit |`SYS_FEES` +|5000 |Interest expense |Expense |Debit |`SYS_INTEREST` +|5100 |Operational adjustments |Expense |Debit |`SYS_ADJUST` +|9999 |Suspense |Asset |Debit |any kind the chart doesn't know, shown rather than hidden +|=== + +`books/Books.java` derives every report from the postings: the journal, the general ledger of one code, an account's T-account, the trial balance, the balance sheet (with the period's earnings as line 3900 until closing the period moves them to retained earnings) and the income statement. Each total is per asset and also valued in dollars at the index price. + +* *Period close.* Closing a period posts one closing entry per asset that moves revenue and expense into retained earnings, and records the close. From then on the ledger refuses any entry dated inside a closed period, so a closed period's statements never change. +* *Reversals.* A posted entry is never edited. A reversal posts the opposite entry, and an entry can be reversed once; closing entries and reversals themselves can't be. +* *Reconciliation.* Each control account (customer deposits, escrow, custody) is compared with the sum of the accounts beneath it, every journal entry with zero, and every stored balance with the sum of its postings. Each difference is reported as a break naming the account and the amounts. +* *Roles.* An `AUDITOR` reads the books and the audit trail and can change nothing; an `ADMIN` operates. Every staff read is appended to the audit chain as `STAFF_READ` before it's answered, and `/api/audit/access-log` lists them. +* *Four eyes.* A manual adjustment or a reversal worth more than `bastion.approval.threshold-usd-cents` waits as a pending approval until a second administrator approves it; the maker can't approve their own request, and the refused attempt stays on the audit chain. The threshold counts what the same administrator posted without a checker in the last 24 hours, so a large change split into small ones still waits. A request in an asset with no dollar price can't be shown to be small, so it waits too. Every change also asks for the administrator's password again. +* *Evidence.* `/api/audit/evidence/{name}` exports the whole journal, the trial balance or the audit chain as CSV or JSON, with its SHA-256 digest, which is itself recorded on the chain. A CSV cell that a spreadsheet would run as a formula is quoted. The chain report names the first row whose link is broken. +* *Long ledgers.* The general ledger and a T-account list the latest 2000 postings and say when there are more; their totals and balances are summed over every posting in the period. + +=== Charts, tables and the console + +The app's screens are in `common/src/main/java/.../ui`. Three classes carry the data views: + +* `Charts.java` draws every chart with `com.codename1.charts`: a filled line for a balance, paired bars for money in and out, a doughnut for the allocation, and candles for a market, made of a range bar from the low to the high under a body from the open to the close. Colors come from the foreground of the `BnChartLine`, `BnChartIn` and `BnChartOut` styles in `theme.css`, so dark mode and a re-skin reach the charts too. +* `Table.java` is a table of weighted columns over `TableLayout`, with numbers aligned to the end of their column. It mirrors in Hebrew, and on a phone a table wider than the screen scrolls sideways inside itself. +* `ConsoleForm.java` is the staff console. A window at least 165 mm wide gets a sidebar; anything narrower gets a section picker. Its buttons follow the roles: an auditor gets no action at all and an administrator's changes send the password with each request and a fresh `Idempotency-Key` with each adjustment or reversal. The server enforces both; the console only stops offering what would be refused. + +`Nav.form` keeps one way out of every screen: a screen reached from the menu has the menu button, a screen pushed from another has a back arrow, never both. The sign-in, code and lock screens have no title bar; `Ui.landing` gives them the brand band as a header instead. + +=== The audit chain + +image::img/bastion-audit-chain.svg["Three audit rows, each holding the hash of the row before it, and the audit_head row pointing at the last; the hash covers length-prefixed fields",scaledwidth=95%] + +`audit/Audit.java` appends every security and money event as a row whose SHA-256 hash covers its own fields, length-prefixed, and the hash of the row before it. Appending a row locks `audit_head`, links to its hash and advances it, inside the caller's transaction, so a transfer that rolls back leaves no audit row behind. *Verify the audit log* (`/api/admin/audit/verify`) walks the chain from its fixed starting hash and reports the first row whose hash isn't its own, that doesn't link to the row before, or a head that doesn't match the last row. + +=== Security features and where they live + +The last column says whether the feature needs a build from the Codename One build server to be in force. Everything marked *No* works in the simulator, a local build and the browser too. + +==== On the server + +[cols="2,4,3,1",options="header"] +|=== +|Feature |What it does |Where |Cloud build? +|Sign-in |The authorization server with the authorization-code flow and PKCE, five-minute access tokens, rotating twelve-hour refresh tokens and the `amr` claim. |`SecurityConfig.java`, `SignInCodePage.java` |No +|Second factor |TOTP enrolment and confirmation, ten single-use recovery codes, and the `/login/mfa` step that holds the session back until the code. |`account/SecondFactor.java`, `SecurityConfig.java` |No +|Passkeys |WebAuthn registration and sign-in with user verification required; listing and removing them. |`SecurityConfig.java`, `account/PasskeyList.java` |No +|Strongest factor enforced |An account with a passkey can't sign in with the password, or with the password and an authenticator code; only the password and a recovery code stand in for the passkey. |`account/SignInGuard.java` |No +|Lockout |Failed passwords counted per account in `login_failure`, locking it for a period after too many. |`account/SignInGuard.java` |No +|Rate limits |Per-address limits on sign-in, registration and transfers. |`SecurityConfig.java` |No +|Headers |`nosniff`, frame denial, `no-store`, a strict referrer policy and HSTS over TLS on every answer. |`SecurityConfig.java` |No +|Ending sessions |A password change ends every other sign-in by removing its grants, so a refresh token on a lost phone stops working. |`account/Grants.java` |No +|Device registration |A device's P-256 public key is registered only within minutes of a sign-in, and with the password again unless the sign-in had a second factor or a passkey. A registration without a platform attestation token is recorded as untrusted, and an untrusted device's signature is refused for any money leaving the customer's accounts. If the app locks while a registration is on its way, the app revokes the server's record of that device after the next unlock. |`money/Devices.java` |No +|Request signing |Signature, timestamp window and single-use nonce on every call that sends value away, and on the calls that would let a stolen token do so later: changing a payee or an address, issuing or unfreezing a card, raising its limit, revoking a device or removing a passkey. Placing an order is signed too: selling into a resting order moves value to its owner. |`money/RequestSigning.java`, `SignedRequest.java` |No +|Device policy |Money leaving the customer needs a signature from a trusted device: registered, not reported rooted, and passing the integrity check. |`money/Money.java`, `money/Devices.java` |No +|Integrity |The check of the platform's attestation token. With `bastion.appattest.app-id` set to the app's team and bundle id, an iPhone's App Attest attestation is verified on the server: the certificate chain to Apple's root, the nonce bound to the device key being registered, the app id, the counter and the environment (`bastion.appattest.production`). Android, and iOS without the setting, use the simulated verifier; see <>. |`money/IntegrityVerifier.java`, `money/AppAttestVerifier.java`, `money/SimulatedIntegrityVerifier.java` |Token: yes +|Limits and step-up |Per transfer, per day, per new payee during its cooling-off; own-account moves above a threshold signed too; crypto counted in dollars at the index price. Orders count against the same per-payment and daily limits, valued in dollars at their worst case, and trade only within a price collar around the index (`bastion.exchange.collar-bps`, 10%). A card's own daily limit is in the card account's currency, as a card network counts it. |`money/Money.java`, `money/Payees.java` |No +|Allow-list |Coins leave only to a signed, cooled-off address. |`exchange/CryptoWallets.java` |No +|Audit chain |Hash-chained event rows appended under a lock on the head. |`audit/Audit.java` |No +|Ledger check |Every entry balances and every balance equals its postings. |`admin/AdminEndpoint.java` |No +|Administrators |No request makes one. Outside development, the only administrator comes from the environment. |`account/DemoAccounts.java` |No +|=== + +==== In the app + +[cols="2,4,3,1",options="header"] +|=== +|Feature |What it does |Where |Cloud build? +|Network guard |Installed before the first request: no cleartext, a host allow-list, and certificate pins, through `AppShield`. If the guard can't be installed the app shows a fatal screen and makes no request. A pinned host that presents no certificate chain, as a connection resumed from a TLS cache can, fails like a mismatch. The one exemption from cleartext is a server on this machine, and only in the simulator, under the tests or in a page served from this machine; an installed app never has it. |`net/Guard.java`, `AppConfig.java` |No +|Over-the-air pins and attestation tokens |`AppShield`'s attestation engine fetches pins and stamps requests with a token. |`net/Guard.java` |Yes +|Sign-in |The strongest way in first: a passkey, then the biometric unlock of a stored sign-in, then the password and second-factor step; then the authorization-code flow with PKCE, in a browser too. |`net/Session.java`, `ui/SignInForm.java` |No +|Copying values |One component for every value a user copies: selectable text and a copy button. Keys and account and card identifiers go through `Protect.copy`, a sensitive clip that clears itself after a minute. |`ui/Ui.java` (`copyable`), `security/Protect.java` |No +|Printing |Statements, receipts, transactions and the console's reports laid out as a page of monospaced lines, drawn on an image and handed to `com.codename1.printing.Printer`. No button where `Printer.isPrintingSupported()` is false. |`ui/Printout.java`, `ui/Table.java` |No +|App key |32 random bytes that seal the device key and the stored tokens, held in memory only while unlocked. Copies: one in the biometric tier of `SecureStorage` (an iOS Keychain item with biometric access control, an Android Keystore key that requires user authentication), one in a `Vault` opened by the unlock PIN through its `KdfProfile` with a pepper from the OS store, and a plain copy only while neither exists. Five wrong PINs wipe every copy. |`security/Keys.java`, `security/SealedStorage.java` |No (biometric binding: a device) +|Token storage |Tokens sealed under the app key in `SecureStorage`: the Keychain on iOS, the Keystore-backed store on Android. |`net/Session.java`, `security/SealedStorage.java` |No +|Device key |A P-256 pair from `KeyGenerator.ec(256)`, the private half sealed with AES-256-GCM in a `SecureEnvelope` under the app key. |`security/DeviceKey.java` |No +|Request signer |Signs the exact bytes the request sends, in the canonical form the server checks. |`security/RequestSigner.java` |No +|App lock |Locks on start with a stored session, after 30 seconds in the background and after two minutes idle; biometric, then PIN, then a fresh sign-in. Locking also drops the session's tokens from memory and ignores every answer still on its way; the unlock reads the tokens back from the sealed store. Signing out erases every key on the device, and a sign-in by another account starts the device over. |`security/Lock.java`, `ui/LockForm.java` |No +|Screen protection |Secure windows on sensitive screens, tapjacking protection, and a clipboard that clears itself after a minute, or as soon as the app can confirm the copy is still there, without reading the clipboard when the app goes to the background. Each copy is a `ClipboardContent` marked sensitive with `setClearAfter`, so Android 13 and newer keep it out of the preview and iOS keeps it local and expires it. On Android 10 and newer a clear that falls due while the app is in the background waits for the app to return, because only then can it confirm the clipboard still holds the copy rather than something the user copied from another app. That wait lives in the running process, so a killed app would leave the copy behind. On Android `Protect` covers it by keeping an HMAC-SHA256 of the copy (never the text) in `Preferences`, under a random key held in secure storage. Once the clear is due it reads the clipboard again as a screen shows, on resume and a moment after the clear: a match clears it, anything else copied since drops the record, and a read that answers nothing (an app without window focus, on Android 10 and newer) keeps the record for the next look. On iOS nothing is recorded or read back, because the pasteboard expires the copy itself and a read would show the paste banner. |`security/Protect.java` |No (a device) +|App switcher cover |A blank card in the iOS app switcher. |`codenameone_settings.properties` |Yes +|Root and jailbreak |Root signals reported to the server; a jailbroken iPhone refuses to start. |`security/DeviceKey.java`, `codenameone_settings.properties` |Jailbreak exit: yes +|Code hardening |Class renaming, string encryption, control-flow flattening and tamper checks. |`codenameone_settings.properties` |Yes +|Security Center |Every protection above with its real state on this device and build. |`security/SecurityStatus.java`, `ui/SecurityCenterForm.java` |No +|=== + +The app can't read its own build hints when it runs, so `SecurityStatus.DECLARED_HINTS` mirrors the hardening hints in `common/codenameone_settings.properties`, and `SecurityStatusTest` fails when the two disagree. + +=== What only a cloud build turns on + +Some protections are applied when the app is built, by the Codename One build server, and do nothing in the simulator, a local build or the browser. The Security Center marks each *Cloud build* there rather than claiming it's on. + +[cols="1,2",options="header"] +|=== +|Build hint |What it turns on +|`cn1.hardenLevel=aggressive` |Code hardening: class renaming, string encryption, control-flow flattening and tamper checks in the built app. +|`android.playIntegrity=true` |Play Integrity: the app obtains an attestation token for the server to check. +|`ios.detectJailbreak=true` |The iOS jailbreak checks; the app exits on a jailbroken iPhone. +|`ios.blockScreenshotsOnEnterBackground=true` |A cover over the app in the iOS app switcher. +|=== + +A hardware-backed keystore, secure windows and biometrics need a real device; the simulator reports them as unavailable. + +=== Settings + +The server reads these at start-up. A system property of the same name wins, then the environment variable the key maps to (`bastion.payee.cooloff-hours` is read from `BASTION_PAYEE_COOLOFF_HOURS`), then `application-.properties`, then `application.properties`. Amounts are in cents. + +[cols="2,1,3",options="header"] +|=== +|Key |Default |What it controls +|`cn1.datasource.url` (`DATABASE_URL`) |none |The database: SQLite, PostgreSQL, MySQL or MariaDB. +|`cn1.security.authorizationserver.issuer` |none |The server's public `https` address, written into every token. +|`cn1.security.authorizationserver.jwk.keys` |a key made at start-up |The key tokens are signed with. Without it, every token stops verifying when the process ends. +|`cn1.security.mfa.encryptionKey` |a fixed development key |The key authenticator secrets are sealed under. +|`bastion.admin.email`, `bastion.admin.password` |none |The one administrator outside the development profile. +|`bastion.webauthn.rp-id` |`localhost` |The domain passkeys belong to. +|`bastion.webauthn.origins` |`http://localhost:8080` |The origins passkeys are accepted from, comma separated. +|`bastion.lockout.attempts`, `bastion.lockout.window-seconds`, `bastion.lockout.seconds` |5, 900, 900 |Failed passwords before the account locks, the window they're counted in, and how long it stays locked. +|`bastion.signin.per-minute`, `bastion.register.per-hour`, `bastion.transfer.per-minute` |10, 20, 20 |Rate limits per address. +|`bastion.device.fresh-minutes` |10 |How recent a sign-in must be to register a device key. +|`bastion.device.policy.enforce` |`true` |Whether money leaving a customer needs a trusted device. Turn it off only for development and screenshots. +|`bastion.stepup.threshold` |100000 |Own-account moves above this are signed by the device too. +|`bastion.exchange.collar-bps` |1000 |How far from the index price, in basis points, an order may trade: the server refuses a limit price outside it and a market order stops at its edge. +|`bastion.limit.per-transfer`, `bastion.limit.daily` |500000, 1000000 |Transfer limits. +|`bastion.payee.cooloff-hours`, `bastion.payee.cooloff-limit` |24, 10000 |How long a new payee cools off and what it can receive meanwhile. +|`bastion.address.cooloff-hours` |24 |How long a new coin address waits. +|`bastion.crypto.limit.per-withdrawal`, `bastion.crypto.limit.daily` |1000000, 2000000 |Coin withdrawal limits, in dollar cents at the index price. +|`bastion.exchange.taker-fee-bps` |20 |The taker fee in basis points. +|`bastion.markets` |BTC-USD and ETH-USD |The markets, as `id:base:quote:lot:index` entries. +|`bastion.approval.threshold-usd-cents` |100000 |Adjustments and reversals worth more than this, in dollar cents, need a second administrator. +|`bastion.demo.history-days` |42 |Days of demo history the development profile posts; above 35 the first four weeks are closed. +|`bastion.market-maker.enabled` |`true` (development only) |The random-walk market maker. +|=== + +In the app, `AppConfig.java` holds the address a release talks to, `RELEASE_SERVER`, the development server the simulator talks to, `ALLOWED_HOSTS` and the certificate `PINS`. The pins in the sample are placeholders, and the Security Center warns about them until they're the SHA-256 digests of the real server's public keys. + +=== The API + +Every endpoint but registration and sign-in needs a bearer token. *Signed* marks the calls that also need the `Bastion-Signature` header; *Idempotent* marks those that take an `Idempotency-Key`. + +[cols="2,3,2",options="header"] +|=== +|Endpoint |What it does |Needs +|`POST /api/account/register` |Opens an account. |Rate limited +|`GET /api/me` |The signed-in customer. | +|`POST /api/account/password` |Changes the password and ends every other sign-in. |The current password, held to the lockout +|`POST /api/account/mfa/totp`, `.../totp/confirm` |Starts and confirms the authenticator app. | +|`POST /api/account/mfa/recovery-codes` |New recovery codes. | +|`GET /api/account/passkeys`, `POST .../{id}/remove` |Lists and removes passkeys. |Removing: signed by a device, or the password in the body +|`GET /api/assets` |The currencies and coins and their decimals. | +|`GET`, `POST /api/accounts` |Lists and opens accounts. | +|`GET /api/accounts/{id}/postings` |An account's history. | +|`POST /api/transfers` |Moves money. |Signed, idempotent +|`POST /api/accounts/{id}/deposit` |The demo deposit. |Idempotent +|`POST /api/accounts/{id}/withdraw` |A withdrawal to the outside. |Signed, idempotent +|`GET`, `POST`, `PUT`, `DELETE /api/payees[/{id}]` |Lists, adds, renames and removes payees. |Adding, renaming, removing: signed +|`GET /api/cards`, `POST /api/accounts/{id}/card` |Lists and issues cards. |Issuing: signed by a trusted device +|`POST /api/cards/{id}/freeze`, `.../unfreeze`, `PUT .../limit` |Freezes, unfreezes and limits a card. |Unfreezing and raising the limit: signed by a trusted device +|`POST /api/cards/{id}/spend` |The demo card spend. |Idempotent +|`GET`, `POST /api/devices` |Lists and registers device keys. |Registering: recent sign-in +|`POST /api/devices/{id}/revoke` |Removes a device key. |Signed by a device, or the password in the body +|`GET /api/exchange/markets`, `.../{id}/book`, `.../{id}/ticker` |Markets, order book and ticker. | +|`POST`, `GET`, `DELETE /api/exchange/orders[/{id}]` |Places, lists and cancels orders. |Placing: signed by a trusted device, idempotent +|`GET /api/exchange/trades` |The customer's trades. | +|`GET`, `POST`, `DELETE /api/crypto/addresses[/{id}]` |The coin address allow-list. |Adding and removing: signed +|`POST /api/accounts/{id}/crypto-withdraw` |Sends coins to an allowed address. |Signed, idempotent +|`GET /api/accounts/{id}/balance-history?days`, `GET /api/insights/cashflow?asset&days`, `GET /api/insights/allocation` |Chart data over the customer's own accounts. | +|`GET /api/exchange/markets/{id}/candles?interval&limit` |Price candles from the trade tape. | +|`GET /api/books/chart`, `.../journal`, `.../journal/{id}`, `.../ledger?code&asset`, `.../accounts/{id}/t-account` |The chart of accounts, the journal, the general ledger and a T-account. |Administrator or auditor, audited +|`GET /api/books/trial-balance?asOf`, `.../balance-sheet?asOf`, `.../income-statement?from&to` |The statements, per asset and in dollars. |Administrator or auditor, audited +|`GET /api/books/reconciliation`, `.../periods`, `.../volume?days` |Reconciliation breaks, closed periods, trading volume. |Administrator or auditor +|`GET /api/audit/chain`, `.../events?after&limit`, `.../trail?subject`, `.../access-log` |The chain report, the events, one entity's trail, and the staff reads. |Administrator or auditor +|`GET /api/audit/evidence/{name}?format&asOf` |Exports `journal`, `trial-balance` or `audit-chain` as `csv` or `json`. |Administrator or auditor, audited +|`POST /api/admin/periods/close` |Closes the books through a time. |Administrator, password +|`POST /api/admin/adjustments`, `POST /api/admin/journal/{id}/reverse` |A manual adjustment or a reversal. |Administrator, password, idempotent; a second administrator above the threshold +|`GET /api/admin/approvals`, `POST .../{id}/approve`, `.../{id}/reject` |The pending approvals and the second administrator's decision. |Administrator, password, not the maker +|`GET /api/admin/audit/verify`, `GET /api/admin/ledger/verify` |The administrator's checks. |Administrator +|=== + +The contracts are the interfaces in `shared/.../api`; the app calls them through the generated `@RestClient` implementations, so a changed path is a compile error on both sides. + +=== Threat model + +Bastion is built against these, the likeliest first: + +[cols="2,4,3",options="header"] +|=== +|Threat |What stops it |Where +|Stolen or guessed passwords |A second factor or a passkey, a password that stops being a way in once a passkey is enrolled, a lockout per account, rate limits per address, and a password minimum checked on the server. |`SecurityConfig.java`, `account/SignInGuard.java` +|A stolen session |Five-minute access tokens; a password change ends every other sign-in; money leaving the account, and every order, also needs a device signature, so a token alone moves nothing out; the step-ups that ask for the password again are held to the sign-in lockout. |`account/Grants.java`, `money/RequestSigning.java` +|Account takeover that adds a mule |A new payee or address cools off for a day with a small limit, and adding one is signed by the device. |`money/Payees.java`, `exchange/CryptoWallets.java` +|A tampered or replayed request |The signature covers the body and the path; each nonce is accepted once within a short time window. |`money/RequestSigning.java` +|An attacker in the network path |No cleartext, a host allow-list and certificate pins in the app; HSTS on the server. |`net/Guard.java`, `SecurityConfig.java` +|A lost or stolen phone |The app lock, keys sealed under an app key that only a biometric or PIN opens (five wrong PINs erase the keys, and the count is kept in the OS secure store beside the PIN's pepper rather than in a preferences file a copy of the phone's storage could reset), removal of the device from another sign-in, and secure windows that keep balances out of screenshots and the app switcher. |`security/Lock.java`, `security/Keys.java`, `security/Protect.java` +|A rooted device or a repackaged app |Root and jailbreak signals, platform attestation, and code hardening in a cloud build. None is proof; the server treats them as signals that decide whether a device is trusted. |`money/Devices.java`, `codenameone_settings.properties` +|A careless or malicious insider |The audit chain makes changing history detectable, the ledger check makes a wrong balance detectable, and no request creates an administrator. |`audit/Audit.java`, `admin/AdminEndpoint.java` +|Races and retries |Conditional balance updates, ordered locks and idempotency keys keep money exact under concurrent and repeated requests. |`ledger/Ledger.java` +|=== + +Out of scope for the sample: a compromised server or database host, an attacker who can read the device's memory while it's unlocked, and denial of service beyond the rate limits. + +=== Tests + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/bastion.sh[tag=bastion-sh-impl-3,indent=0] +---- + +The server's tests cover the ledger's invariants under concurrent transfers and trades, idempotency, sign-in with each factor, the access rules of every endpoint, signing and replay, the device policy, limits and cooling-off, the matching engine and its rounding, and the audit chain's detection of a changed row. `run-e2e.sh` registers two customers, enrols an authenticator and signs in with it, registers device keys, makes a signed payment and checks that the unsigned and replayed copies are refused, fills an order between the two, and checks that a withdrawal is refused until its address is allowed and cooled off. `run-web-e2e.sh` does it in a headless browser, through the app's own screens: it opens an account, turns on the authenticator app, creates a passkey, signs in again with the password and a code and checks that the server refuses the code because the account has a passkey, signs in with the passkey instead, moves money between two accounts, then signs in with the passkey alone and registers the browser as a device without the password. With `--guide` it runs the server on the development profile with the device policy off, then signs in as the demo customer and the administrator and photographs each screen; run it after changing a screen so the user's guide stays true. + +=== Making it yours + +* *Name, icon and colors.* The name and package come from the Initializr, the icon is `common/icon.png`, and the colors are the variables at the top of `common/src/main/css/theme.css`, with a dark set below them. +* *Server address and pins.* Set `AppConfig.RELEASE_SERVER` to your API's `https` address, `AppConfig.ALLOWED_HOSTS` to its host and `AppConfig.PINS` to its key digests before the first release build. +* *Languages.* The bundles are in `common/src/main/l10n`; `BundleTest` checks that every bundle has every key and every server refusal code. +* *Markets and assets.* `bastion.markets` lists the markets; the assets are rows in the ledger's migrations (`V2__ledger.sql`), served to the app by `/api/assets`. +* *Limits.* Every limit and cooling-off period is a setting in the table above. + +[[bastion-chain]] +=== On a real chain + +With `bastion.chain.rpc-url` set, ether is real: deposits arrive at an address of the customer's own, withdrawals are signed and broadcast, and the ledger follows the chain. Left empty, ether is a ledger balance like the other coins. The code is in `backend/.../chain`, and each piece follows a published standard, checked against known answers in `ChainVectorTest`: + +[cols="2,4",options="header"] +|=== +|Piece |What it does +|`Rlp`, `Eip1559Tx` |Encodes and signs type-2 transactions (EIP-1559), with a chain id so a signature can't be replayed on another network (EIP-155). +|`EthKeys` |secp256k1 signatures with deterministic nonces (RFC 6979) and low-S values, and Keccak-256, from the Codename One security API. +|`Bip32`, `SeedSigner` |Derives every key from one seed the way wallets do (BIP-32, BIP-44): deposit address number _n_ is `m/44'/60'/0'/0/n`, the hot wallet `m/44'/60'/1'/0/0`. A key is wiped after each signature. +|`EthAddress` |Checksummed addresses (EIP-55). A withdrawal address with a wrong checksum is refused when it's added. +|`EthRpc` |Talks JSON-RPC to a node over HTTPS (plain HTTP only to the local host). +|`Chain` |Scans blocks, credits deposits, signs, broadcasts and settles transfers, sweeps deposits into the hot wallet, and checks reserves. +|=== + +How the money stays right: + +* *Confirmations and reorganizations.* A block is read only once it's `bastion.chain.confirmations` deep, and each block's parent must be the block read before it. If it isn't, the chain reorganized deeper than the setting allows: scanning stops, an audit event is written, and nothing more is credited until someone looks. Only transactions whose receipt reports success are credited. +* *Exactly once.* A deposit is posted under the key `chain:in:`, so reading a block twice credits it once. Every transfer moves between states by a conditional update, and one instance at a time holds a lease on the chain, so two servers or two overlapping ticks can't sign the same withdrawal twice. +* *Nonces.* A withdrawal takes the larger of the node's pending nonce and the highest nonce the database has used, plus one. A transaction that's dropped is broadcast again after `bastion.chain.rebroadcast-seconds`, unchanged, so it can only ever land once. +* *Withdrawals in transit.* A withdrawal moves the coins to the liability account 2300 when it's requested. A confirmed one moves them out to the chain; a failed one returns them to the customer. The network fee is posted to the expense account 5200 at what the receipt says was spent. +* *Rounding toward the bank.* The ledger counts ether in gwei. Deposits round down and fees round up, so the ledger never claims coins the chain doesn't hold. +* *Reserves.* Before signing a withdrawal, the server adds up what it owes (every customer's ether plus what's in transit) and what it holds on the chain. If it holds less, or the hot wallet can't pay the amount and the worst-case fee, the withdrawal waits, says why, and an audit event `CHAIN_RESERVES_SHORT` is written. Coins the demo data would mint aren't minted for a coin on a real chain. +* *A float for fees.* Sweeps and withdrawals pay the network out of the bank's own coins, while the customer is still owed the full deposit. Until the hot wallet holds a float of the bank's own, the first sweep leaves custody short by its fee and the reserves check holds every withdrawal. Fund the hot wallet before opening deposits, and alert when the float runs low. +* *The wrong network.* At startup the node's chain id must match `bastion.chain.id`, or the server refuses to start rather than sign for another network. + +The console's custody report (`/api/books/custody`) shows the hot wallet, what's on the chain, what's owed and what's in transit. + +[cols="2,1,4",options="header"] +|=== +|Setting |Default |Meaning +|`bastion.chain.rpc-url` |empty |The node. Empty turns the chain off. +|`bastion.chain.id` |11155111 (Sepolia) |The network the node must be on. +|`bastion.chain.confirmations` |12 |How deep a block must be before it's read. +|`bastion.chain.seed` |none |64 hex digits of seed. Keep it in a secret store, and in production replace `SeedSigner` altogether. +|`bastion.chain.rebroadcast-seconds` |300 |When a transaction that hasn't landed is sent again. +|`bastion.chain.sweep-minimum-gwei` |1000000 |The smallest deposit balance worth a sweep's fee. +|`bastion.chain.period-ms` |15000 |How often the chain is read. +|=== + +`run-chain-test.sh` checks the whole path against a local development node in a container: a deposit is credited after its confirmations and swept, a withdrawal arrives on the chain to the wei, the ledger and custody agree, and the reserves check holds a withdrawal the chain can't back. To try it on a public test network, point `bastion.chain.rpc-url` at a Sepolia node, set the seed, and fund the deposit address the app shows from a faucet. + +What the chain code doesn't do yet: tokens (ERC-20 transfers are calls into a contract and need their own scan of the transfer logs), deposits sent by a contract rather than in a plain transaction, more than one withdrawal in flight per nonce at a time, and a fee policy beyond twice the base fee plus the node's suggested tip. + +[[bastion-security-checklist]] +=== Security checklist + +Work through this before a release. The first list is what the sample already does; keep each item true as you change it. The second is what a product has to add. + +*Already in place, keep it so:* + +* [ ] Passwords hashed with an adaptive hash, a minimum checked on the server, and a lockout per account with rate limits per address and per user. +* [ ] A second factor or passkey for every sign-in that registers a device, and for every staff session that reaches the console. +* [ ] Short-lived access tokens; a password change ends every other session. +* [ ] Money leaving an account, every order and every new payee or address signed by a registered device key, over the body, the path and a single-use nonce. +* [ ] New payees and addresses cool off, with a daily cap on how many are added; removing one and adding it again doesn't restart the clock. +* [ ] Every balance change is a balanced ledger entry under an idempotency key, with conditional updates and ordered locks. +* [ ] The audit log is hash-chained, and the ledger and audit checks run and alert. +* [ ] The server refuses to start outside development with default token keys, encryption keys or passkey origins. +* [ ] No cleartext, a host allow-list and certificate pins in the app; HSTS on the server; forwarded addresses trusted only from configured proxies. +* [ ] Keys on the device sealed by the OS and opened by a biometric or PIN; the app locks in the background and keeps balances out of screenshots. +* [ ] Withdrawal addresses validated by their checksum; chain transfers only after confirmations, with the reserves check on. + +*A product adds:* + +* [ ] Server keys, the authenticator-sealing key and chain keys in an HSM or key service; cold storage for most reserves, with signing that needs more than one person. +* [ ] `bastion.appattest.app-id` set so iPhones are attested by Apple, a Play Integrity verifier for Android, and a policy for what an unattested device may do. +* [ ] The audit head published at intervals to storage the database's administrators can't write. +* [ ] Identity verification, sanctions screening and transaction monitoring, and the reports the law asks for. +* [ ] A web application firewall and a proxy in front of the server, with `cn1.server.trustedProxies` set to it. +* [ ] Monitoring and alerting on sign-in failures, refused signatures, reserve shortfalls, reorganizations and the ledger check, with an incident response plan that's been rehearsed. +* [ ] Encrypted backups that have been restored in a test, and a plan for the loss of a region. +* [ ] A support console with its own second factor, least privilege and audit. +* [ ] Dependencies and build tools kept current; release builds from a clean machine with code hardening on, and their signing keys off developer machines. +* [ ] A penetration test and a code review by someone outside the team, before launch and after major changes. +* [ ] The certifications your market expects, such as PCI DSS for cards, SOC 2, strong customer authentication rules, and data protection law for personal data and its deletion. + +[[bastion-before-product]] +=== Before it's a product + +The sample is complete as a demonstration and stops where a real bank's own systems begin. A product needs at least: + +* *Identity verification.* Opening an account takes an e-mail and a password. A bank verifies who the customer is (know your customer) through a vendor, screens them against sanctions lists, and keeps the evidence. +* *Payment rails and core banking.* Deposits are a counter and transfers stay inside the sample's ledger. Real money arrives and leaves through card networks, bank transfers and instant payment schemes, and a bank's core banking system is usually the ledger of record, which this ledger would then mirror or replace. +* *Keys in a hardware security module.* The server's token-signing key, the key that seals authenticator secrets, and any card or custody keys belong in an HSM or a cloud key service, never in the server's configuration. +* *Keys out of the server for ether.* The chain code in <> works on a real network, but `SeedSigner` derives its keys from a seed in the server's settings. A product binds `ChainSigner` to a key service or an HSM, and moves most reserves to cold storage that needs people to sign. +* *Bitcoin.* Only ether settles on a chain. Bitcoin stays a ledger balance until it gets an adapter of its own. +* *A real integrity check on Android.* `AppAttestVerifier` checks an iPhone's attestation against Apple's root, but `SimulatedIntegrityVerifier` still believes an Android device. Production sends the Play Integrity token to Google's `decodeIntegrityToken` with the project's service account. A product also sends App Attest receipts to Apple's fraud metric service, and checks the signed assertions a device sends after it's registered. +* *A real market.* The market maker is a development-only random walk. A product takes its prices from a market data provider and its liquidity from real counterparties, and runs the exchange's own surveillance. +* *Regulatory reporting.* Suspicious activity reports, transaction reporting, statements and tax documents, and records kept for as long as the law asks. +* *Audit anchoring off the database.* The audit chain shows that history was changed, but whoever controls the database can rewrite the whole chain. Publishing the chain's head at intervals to a store the database's administrators can't write, such as write-once storage or a timestamping service, closes that. +* *Reviewed translations.* The non-English bundles are machine translations; have them reviewed before release. +* *A PIN checked off the device.* The attempt counter is in the OS secure store beside the pepper, so restoring the app's files doesn't reset it, and the pepper keeps a copy of those files from being tried offline. Both still sit on the device: someone who controls the OS store controls the counter too. A product that must hold against that checks the PIN on the server or in a secure element. +* *Biometric-bound keys on the web and desktop.* Only iOS and Android bind a key to a biometric in the OS. In a browser and on the desktop the lock offers the PIN, and the Security Center says so. +* *Your own origin for passkeys.* A deployment sets `bastion.webauthn.rp-id` and `bastion.webauthn.origins` to its own domain, and adds the Android app's `android:apk-key-hash:` origin and the iOS app's associated domain. +* *Operations.* Monitoring and alerting on the audit and ledger checks, fraud scoring on transfers, a support console with its own access controls and audit, backups and disaster recovery, and penetration testing. diff --git a/docs/developer-guide/Bastion-Users-Guide.asciidoc b/docs/developer-guide/Bastion-Users-Guide.asciidoc new file mode 100644 index 00000000000..b6d634971cf --- /dev/null +++ b/docs/developer-guide/Bastion-Users-Guide.asciidoc @@ -0,0 +1,229 @@ +[[bastion-users-guide]] +== Bastion: A bank that shows its locks + +Bastion is a virtual bank written in Codename One from end to end: a phone and desktop app, and the server it talks to. It holds money in several currencies and coins, moves it between accounts and people, issues cards, and runs a small exchange where customers trade bitcoin and ether against dollars. It's built to show the security a financial app needs, and every protection it has is listed, with its real state, on one screen of the app. + +The sample lives in `scripts/bastion`. This chapter is about using it: what a customer and an administrator see and do. The next chapter, <>, is about how it's built and what a real bank would still have to add. + +Out of the box nothing in Bastion is real money. Deposits are a counter, cards spend nowhere, and the exchange trades against a simulated market. Ether can be made real: connected to an Ethereum node, the app shows a deposit address under *Receive crypto*, and deposits and withdrawals settle on the chain. The security mechanisms are the ones a production bank would use: the passwords, codes, passkeys and signatures work as they would there. The audit chain is hashed the same way, but its head isn't anchored anywhere outside the server's own database, so someone who controls that database could rewrite it and the chain along with it. + +=== Trying it + +The server runs on the JVM with an in-memory database and demo accounts, with nothing to install but a JDK 17: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/bastion.sh[tag=bastion-sh-users-1,indent=0] +---- + +The app then runs in the simulator from another terminal: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/bastion.sh[tag=bastion-sh-users-2,indent=0] +---- + +`CN1_PROFILE=dev backend/server.sh run --web` also builds the app for the browser and serves it at the server's own address, `http://localhost:8080/`. The screenshots in this chapter come from that browser build, at a phone's width. + +The development profile creates three demo accounts, all with the password `bastion-demo-password`: + +[cols="1,2",options="header"] +|=== +|Account |What it's for +|`customer@bastion.example` |A customer with $2,500.00 in a checking account +|`customer2@bastion.example` |A second customer with $1,200.00, to pay and to trade with +|`admin@bastion.example` |The administrator +|=== + +On the development profile a small market maker also keeps the exchange's books moving, so that there is something to trade against. + +=== Opening an account + +image::img/bastion-sign-in.png["The sign-in screen with fields for e-mail and password and buttons to sign in, sign in with a passkey and open an account",scaledwidth=40%] + +*Open an account* on the sign-in screen walks through the steps a new customer takes: + +. *Name, e-mail and password.* The password has to be at least twelve characters and can't be the e-mail address; the server checks it too, not only the app. +. *Authenticator app.* The app shows a QR code and the same secret as text. Scanning the code with any authenticator app and typing the six digits it shows turns on the second factor. Adding the app asks for the password again after a sign-in made with the password alone, so a stolen session can't put somebody else's app on the account. The secret can be copied instead; the copy is marked sensitive, which keeps it out of the keyboard's suggestions on Android and off the user's other devices on iOS, and it's wiped from the clipboard after a minute, or at the next start if the app was closed before then. Bastion then shows ten recovery codes, once, to write down or copy into a password manager the same way. Each opens the account once if the phone is lost; keep them somewhere other than the phone. +. *Passkey.* Where the device or browser supports passkeys, one can be added now, so that later sign-ins need no password at all. +. *Unlock with biometrics.* An option on a phone with a fingerprint or face enrolled: the key that opens the stored sign-in and the payment key is released only by the biometric. +. *Unlock PIN.* An option: six to twelve digits that open the lock when a biometric isn't available or isn't wanted. Five wrong PINs in a row erase the keys on the device, and the way back is signing in again. +. *This device.* The app makes a signing key that never leaves the device and registers it with the bank. Payments to other people are signed with this key, so the device itself is a second proof of who is paying. The bank adds a device only after a sign-in made with an authenticator code or a passkey; after a sign-in with the password alone, the app asks for one of those to be set up first. + +image::img/bastion-totp-enrolment.png["The second-factor step with a QR code, the secret as text, buttons to copy it or open an authenticator app, a field for the six-digit code and a Turn on button",scaledwidth=40%] + +A new account starts with a dollar checking account. Others, in euros or as coin wallets, are opened from the dashboard. + +=== Signing in + +The sign-in screen offers the strongest way in the device has first. Where the device or browser supports passkeys, *Sign in with a passkey* is the main button: the passkey sheet asks for a fingerprint, a face or the device PIN, and that one step is both factors. On a phone that keeps a sign-in behind biometric unlock, *Unlock with fingerprint or face* comes next. The e-mail and password, then a code from the authenticator app when the account has one, are the last way in. + +Once an account has a passkey, the server holds it to the passkey. The password is refused, and the code step after it takes a recovery code and nothing else; a six-digit code from the authenticator app no longer signs in. That's the way back for a customer whose passkey is gone: the password and one of their recovery codes, each of which works once. An account with a passkey and no authenticator app has no recovery codes, so its way back is the bank. Five wrong passwords in a row lock the account for fifteen minutes; the lock is per account, so guessing from many places at once doesn't help. + +image::img/bastion-sign-in-code.png["The second step of signing in, asking for the six-digit code from the authenticator app",scaledwidth=40%] + +image::img/bastion-sign-in-passkey-required.png["The code step refusing an authenticator code because the account has a passkey, with a button to sign in with the passkey",scaledwidth=40%] + +=== The lock + +The app locks itself after half a minute in the background and after two minutes without a touch. Locked, it shows nothing of the account and can't sign a payment or read its stored sign-in until it's unlocked again: with biometrics when they're turned on, then with the unlock PIN when one is set, and otherwise by signing in again. Signing in again from the lock sets the device up from the start when a biometric or PIN was set up, so its payment key is registered again the next time a payment needs it. Signing out from the menu does the same, so whoever signs in next on the device starts with none of the previous account's keys. + +image::img/bastion-lock.png["The lock screen: Bastion is locked, a PIN field, an Unlock with PIN button and a Sign in again button",scaledwidth=40%] + +=== The dashboard + +The dashboard shows the total of every account in dollars, at the exchange's current prices, the cash in dollar accounts over the last 30 days, how the money is spread across currencies and coins, and each account with its balance. Until the device has a payment key, a banner above says so and offers to register it; registering asks for the password again, because only a recent sign-in may add a key. + +image::img/bastion-dashboard-unregistered.png["The dashboard with a banner saying this device can't sign payments yet and a Register this device button",scaledwidth=40%] + +image::img/bastion-dashboard.png["The dashboard with the total balance, buttons to move money, pay someone and trade, and the checking account",scaledwidth=40%] + +Screens reached from the menu are top-level and have the menu button; a screen opened from another one has a back arrow instead. No screen has both. + +Tapping an account opens it: its balance, what can be done with it, a chart of the balance over 30 days, the money in and out each day, and its history, every posting newest first. + +image::img/bastion-account-history.png["A checking account with buttons to move, add and withdraw money and to order a card, a balance line chart and bars of money in and out",scaledwidth=40%] + +Tapping a line of the history shows that posting in full: the side of the entry it's (a credit to the account or a debit from it), the balance it left and the ledger entry it belongs to. *Statement* lists every posting oldest first with the running balance between an opening and a closing balance, the way a bank statement does. + +image::img/bastion-transaction.png["One posting: money in, the amount, the side of the entry, the balance after it and the ledger entry reference",scaledwidth=40%] + +image::img/bastion-statement.png["A statement table with date, description, money in, money out and balance columns between an opening and a closing balance",scaledwidth=40%] + +The app follows the device's dark mode, charts included: + +image::img/bastion-dashboard-dark.png["The dashboard in dark mode with the balance chart and the allocation ring",scaledwidth=40%] + +Balances, card numbers and codes are on *sensitive screens*. On Android those screens can't be captured or recorded, and the recent-apps list shows them blank; on iOS the app switcher shows a cover instead of the screen. + +=== Moving money + +*Move money* moves money between your own accounts in the same currency. *Pay someone* sends it to a payee: another Bastion customer, added by e-mail on the *Payees* screen. + +image::img/bastion-payees.png["The payees screen with one payee, Robin, marked Cooling off with the time the limit ends",scaledwidth=40%] + +image::img/bastion-transfer.png["The pay someone form with the account to pay from, the payee, an amount and a Review button",scaledwidth=40%] + +*Review* shows what's about to happen, and *Send* sends it. The receipt shows the reference of the entry it made in the ledger. + +image::img/bastion-transfer-confirm.png["A confirmation dialog: Send $20.00 from Checking to Robin, with Cancel and Send",scaledwidth=40%] + +image::img/bastion-receipt.png["The receipt with the amount, the ledger reference and the time",scaledwidth=40%] + +Every transfer carries a key that makes it happen once: a payment retried after a dropped connection isn't paid twice. Some transfers ask for more: + +* A payment to someone else is signed with this device's key. On a device that has no key yet, the app offers to register one first, then sends the same payment. +* By default, money leaves your accounts only from a device the bank trusts: a phone that passed the platform's integrity check and doesn't look rooted or jailbroken. A browser or the simulator can't prove that, so there the server refuses the payment, explaining that the device can move money only between your own accounts. The screenshots above were taken with that policy turned off on the development server (<> has the setting). +* A new payee is in a cooling-off period for its first day, in which it can receive only $100.00. Someone who has taken over a session can't add an account of their own and empty yours into it. +* A move between your own accounts above $1,000.00 is signed with the device key too. +* Every transfer is held to a limit per transfer ($5,000.00) and per day ($10,000.00); one past either is refused. + +=== Cards + +*Order a card* on a checking account issues a card on it. *Cards* lists them, freezes and unfreezes each, and sets its daily spending limit. A frozen card is refused at once. Freezing works from any signed-in session; unfreezing a card or raising its limit needs this device's approval, so somebody holding only your session can't undo a freeze. The card number is on a sensitive screen. + +image::img/bastion-cards.png["The cards screen with a card, its masked number, its daily limit and buttons to freeze it and change the limit",scaledwidth=40%] + +=== The exchange + +*Exchange* lists the markets, BTC-USD and ETH-USD, with their last price and the index price the bank values coins at, and the customer's recent trades. + +image::img/bastion-exchange.png["The exchange screen listing the BTC/USD and ETH/USD markets with their prices",scaledwidth=40%] + +image::img/bastion-order-book.png["The BTC/USD market: the last price, the bid and ask, hourly candles with the volume under them, and the order book",scaledwidth=40%] + +A market opens the last day in hourly candles, a rising hour in green and a falling one in red over its high-low range, the volume traded each hour, its order book and a ticket: + +* A *limit order* names a price and a quantity, and rests on the book until another order meets it, or until it's cancelled. +* A *market order* takes what the book offers now, up to a budget for a buy or a quantity for a sell. + +Placing an order sets aside what it could spend; the rest of the balance stays free. Fills move both sides and the fee in one ledger entry, so a trade is never half done. The *Open orders* list shows what's still resting, each with a cancel button, and a bought coin lands in a wallet account of its own. + +image::img/bastion-order-filled.png["The market after a market buy: the order book, the order ticket and the customer's filled order",scaledwidth=40%] + +=== Sending coins + +Coins leave the bank only to an address on the account's *allow-list*, and the list is guarded: + +* Adding an address is signed by this device. +* A new address waits a day before anything can be sent to it. +* Every withdrawal is signed by this device, and is held to a limit per withdrawal and per day, counted in dollars. + +image::img/bastion-withdraw.png["The send crypto screen with the wallet to send from and an address on the allow-list, usable only from tomorrow",scaledwidth=40%] + +=== Security Center + +*Security Center* lists every protection the app has and whether it's in force on this device, in this build, now: the connection rules and certificate pins, where the keys and the sign-in are kept, the app lock, the screen protections, whether the device looks rooted or jailbroken, whether it passed the platform's integrity check, and the hardening the build applied. + +image::img/bastion-security-center.png["The Security Center listing network protections with Warning and Cloud build badges",scaledwidth=40%] + +It's honest by design. A protection the simulator or a browser can't provide, such as a hardware keystore, says so. One that only a build from the Codename One build server applies, such as code hardening or Play Integrity, is marked *Cloud build* instead of claiming to be on. A development exemption in use, such as the unpinned connection to a server on this machine, is a *Warning*. + +=== Settings + +Every value a customer may need somewhere else has a copy button beside it, and the value itself can be selected: a receipt's reference, a transaction's ledger entry and posting, an account and a card identifier, a coin address, a passkey's ID, this device's key ID, the authenticator key, and on the console an entry's ID and the audit and evidence hashes. Keys and account and card identifiers are copied as sensitive clips that clear themselves after a minute, the same as the authenticator key; a short message confirms each copy. + +Statements, receipts and transactions have a *Print* button, as do the console's reports: the general ledger, the trial balance, the balance sheet, the income statement, the audit chain, the audit trail, the access log and an evidence export. The printout has the bank's name, the report, the account and period it covers and when it was made, then the table, then the totals, and goes to the platform's print dialog. Where the platform can't print, there is no button. + +*Settings* turns biometric unlock on and off, sets, changes or removes the unlock PIN, turns on the authenticator app and makes new recovery codes, lists and removes passkeys and adds new ones, shows this device's payment key, and chooses the language: English, Spanish, French, German or Hebrew, which lays the app out right to left. Tables mirror, so the debit side of a T-account is on the right as a Hebrew ledger writes it; charts keep time running left to right. + +image::img/bastion-settings.png["The settings screen with the unlock options, the authenticator app, passkeys, this device's payment key and the languages",scaledwidth=40%] + +=== The back office + +Staff sign in like customers and land on the console: *Administration* for an administrator, *Audit* for an auditor. On a wide window, a desktop or a tablet on its side, the sections are a sidebar beside the page and the tables use the width; on a phone the sections are a picker over one column, and a wide table scrolls sideways. A customer's statement on a phone keeps three columns instead: the date, the amount signed in or out, and the running balance. + +image::img/bastion-console-overview.png["The administration console on a desktop: a sidebar of sections, tiles for the trial balance, reconciliation, audit chain and approvals, volume charts and the two integrity checks",scaledwidth=95%] + +The *Overview* shows whether the trial balance balances, whether reconciliation found breaks, whether the audit chain is intact and how many requests wait for approval, with the dollar volume and the number of journal entries a day. Order holds and their releases are left out of both: they move a customer's own money into escrow and back, and no value changes hands. An administrator also runs the two checks, recomputed from the stored rows each time: + +* *Verify the audit log*: every security and money event is a link in a hash chain, and the check reports any row that was changed, removed or inserted out of order. +* *Verify the ledger*: every entry sums to zero in each currency, and every balance equals the sum of its postings. + +The *Journal* lists every entry with its debit (Dr) and credit (Cr) legs, credits indented under the debits. Tapping an entry shows it in full, with a total per currency that proves its debits equal its credits, the entry it reverses or the one that reversed it, and its audit trail. + +image::img/bastion-console-journal.png["The journal: each entry's date, reference, type and memo over its legs, with debit and credit columns",scaledwidth=95%] + +image::img/bastion-console-entry.png["One journal entry: when it was posted, its type, who initiated it, its legs with codes, owners and debit and credit amounts, a total per currency and a Balanced badge",scaledwidth=95%] + +The *General ledger* lists the lines of one account code in one currency with a running balance, and shows the account as a T-account, debits on one side and credits on the other, between its opening and closing balances. + +image::img/bastion-console-t-account.png["A T-account for customer deposits in dollars: debits on the left, credits on the right, totals under each and the closing balance",scaledwidth=95%] + +The statements follow from the same postings. The *Trial balance* lists the debit or credit balance of every account code and currency, with totals that must agree. The *Balance sheet* shows assets, liabilities and equity, and that the first equals the sum of the other two. The *Income statement* shows revenue and expenses for the period and the net income, all valued in dollars as well as in their own currency. + +image::img/bastion-console-trial-balance.png["The trial balance: code, account, class, asset, debit and credit columns with a total per currency and a Balanced badge",scaledwidth=95%] + +image::img/bastion-console-balance-sheet.png["The balance sheet with sections for assets, liabilities and equity, each line in its own currency and in dollars",scaledwidth=95%] + +image::img/bastion-console-income-statement.png["The income statement with revenue and expenses and the net income in dollars",scaledwidth=95%] + +*Reconciliation* compares each control account with the sum of the customer accounts under it. A difference is a break, and its row is red. + +image::img/bastion-console-reconciliation.png["Reconciliation: each control account's balance against its subledger with the difference, and the list of breaks",scaledwidth=95%] + +*Period close* lists the closed periods. Closing one moves revenue and expense into retained earnings and locks every entry up to its date, so its statements never change again. + +image::img/bastion-console-periods.png["The period close section listing closed periods with who closed them and when, and a button to close the books",scaledwidth=95%] + +Changes need two administrators. An administrator can ask for an adjustment to a customer account or for an entry to be reversed; above the approval threshold it waits in *Approvals* until a second administrator approves or rejects it. Every change asks for the administrator's password again. + +image::img/bastion-console-approvals.png["The approvals queue with a pending adjustment highlighted, its maker, memo and status",scaledwidth=95%] + +image::img/bastion-console-approve.png["The decision dialog: the request, a reason, the password and Approve, Reject and Cancel buttons",scaledwidth=95%] + +*Audit chain* reports how many links were checked and the head of the chain, and lists the latest events with their hashes; if a link is broken, the first broken row is red. *Audit trail* lists every event that names one entry, account or person, and *Access log* lists every time staff read the books. + +image::img/bastion-console-audit-chain.png["The audit chain: an Intact badge, the number of links checked, the head hash and the latest events with their previous and own hashes",scaledwidth=95%] + +*Evidence* exports the journal, the trial balance or the audit chain as CSV or JSON for an external auditor. The file is saved, a browser downloads it, and the screen shows who made it, when, and its SHA-256 digest so the copy can be checked later. + +image::img/bastion-console-evidence.png["The evidence section after a CSV export: the file name, who made it and when, its SHA-256 digest and the first lines",scaledwidth=95%] + +An auditor sees the same console marked *Read-only*, without the approvals queue and without any button that changes something. The server refuses those changes to an auditor whatever the app shows. + +image::img/bastion-console-auditor.png["The audit console marked read-only, showing the access log of staff reads",scaledwidth=95%] + +image::img/bastion-console-phone.png["The audit console on a phone: a section picker over a single column",scaledwidth=40%] + +No request can make an administrator. Outside the development profile, the one administrator account comes from the server's environment when it starts. diff --git a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc index 9903b36320c..ad2026a2dc9 100644 --- a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc +++ b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc @@ -240,6 +240,16 @@ override clipboard handling in `CodenameOneImplementation` and return the format the system clipboard. Applications can use the same API for domain-specific formats without changing the editor. +A password, a one-time code or a recovery key shouldn't linger on the clipboard. +`ClipboardContent.setSensitive(true)` marks a copy as sensitive: Android 13 and newer keep its text +out of the copy preview and the keyboard's suggestions (`ClipDescription.EXTRA_IS_SENSITIVE`), and +iOS publishes it local only, so Universal Clipboard never sends it to the user's other devices. The +simulator and the browser have no such marker. `setClearAfter(millis)` takes the copy off the +clipboard again after that time. iOS hands the expiry to the pasteboard, which removes the copy even +if the application was suspended or closed meanwhile. The other ports clear it from the application +when the time is up, and only if the application copied nothing else since; Android also checks that +the clip on the clipboard is still its own, so a later copy from another application stays. + RTF is also recognized when a clipboard provider returns it as a string beginning with an RTF header. Format import is intentionally a practical interchange subset, not a layout-compatible word processor: unsupported RTF destinations and advanced Markdown or AsciiDoc extensions degrade to the diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 3bc0fa4bb19..1c4157334ae 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -162,6 +162,10 @@ include::Parley-Users-Guide.asciidoc[] include::Parley-Implementors-Guide.asciidoc[] +include::Bastion-Users-Guide.asciidoc[] + +include::Bastion-Implementors-Guide.asciidoc[] + = Device and platform services include::Push-Notifications.asciidoc[] diff --git a/docs/developer-guide/img/bastion-account-history.png b/docs/developer-guide/img/bastion-account-history.png new file mode 100644 index 00000000000..ace58910f81 Binary files /dev/null and b/docs/developer-guide/img/bastion-account-history.png differ diff --git a/docs/developer-guide/img/bastion-architecture.svg b/docs/developer-guide/img/bastion-architecture.svg new file mode 100644 index 00000000000..73847e7cd35 --- /dev/null +++ b/docs/developer-guide/img/bastion-architecture.svg @@ -0,0 +1,72 @@ + + + + How Bastion is put together + One Maven project: the app in common, the server in backend, and the contracts both compile in shared. + + common: the app (iOS, Android, desktop, browser) + + ui/ + every screen, Nav side menu, LockForm + + net/Guard + no cleartext, host allow-list, pins + + net/Session + password + code, PKCE, passkeys + + security/Keys + app key: biometric, PIN vault + + security/DeviceKey + P-256 pair, sealed under app key + + security/RequestSigner + Bastion-Signature header + + security/Lock, Protect + app lock, secure windows + + security/SecurityStatus + what the Security Center shows + + shared + @RestClient + contracts, + DTOs, + SignedRequest + + backend: the server (JVM or native binary) + + SecurityConfig + authorization server, MFA, passkeys + + account/ + sign-up, lockout, grants, passkeys + + money/ + transfers, payees, cards, devices + + money/RequestSigning + signature, nonce, timestamp + + exchange/ + order books, matching, allow-list + + ledger/Ledger + the only writer of a balance + + audit/Audit + hash-chained event log + + admin/ + ledger and audit verification + + + HTTPS + JSON + bearer token, + signed money calls + + SQLite, PostgreSQL, MySQL or MariaDB + + diff --git a/docs/developer-guide/img/bastion-audit-chain.svg b/docs/developer-guide/img/bastion-audit-chain.svg new file mode 100644 index 00000000000..77137a6cc5d --- /dev/null +++ b/docs/developer-guide/img/bastion-audit-chain.svg @@ -0,0 +1,52 @@ + + + + The audit chain + Every security and money event is a row whose hash covers its fields and the hash of the row before it. Changing, + removing or inserting a row breaks every link after it, and Verify the audit log reports the first broken one. + + seq 41 + prevHash 9f3c..e1 + at 1792... + actor customer@... + action SIGN_IN + subject mfa + hash a71b..04 + + + seq 42 + prevHash a71b..04 + at 1792... + actor customer@... + action DEVICE_ADD + subject dev 7c.. + hash 5d20..b9 + + + seq 43 + prevHash 5d20..b9 + at 1792... + actor customer@... + action TRANSFER + subject tx 31.. + hash c4e8..72 + + audit_head + seq 43 + hash c4e8..72 + locked by each append + + + hash = sha256 of length-prefixed fields + prevHash | seq | at | actor | action | subject + | detail | address + Length prefixes keep "ab"+"c" and "a"+"bc" apart. + Genesis: the chain starts from a fixed hash. + + Append and verify (audit/Audit.java) + append: lock audit_head, link to its hash, insert, + advance the head -- in the caller's transaction, so a + rolled-back transfer leaves no audit row behind. + verify: walk from genesis, recompute every hash, and + check the last row is the head. + diff --git a/docs/developer-guide/img/bastion-cards.png b/docs/developer-guide/img/bastion-cards.png new file mode 100644 index 00000000000..a1d95b25ae3 Binary files /dev/null and b/docs/developer-guide/img/bastion-cards.png differ diff --git a/docs/developer-guide/img/bastion-console-approvals.png b/docs/developer-guide/img/bastion-console-approvals.png new file mode 100644 index 00000000000..ece2ae040af Binary files /dev/null and b/docs/developer-guide/img/bastion-console-approvals.png differ diff --git a/docs/developer-guide/img/bastion-console-approve.png b/docs/developer-guide/img/bastion-console-approve.png new file mode 100644 index 00000000000..f902e00148f Binary files /dev/null and b/docs/developer-guide/img/bastion-console-approve.png differ diff --git a/docs/developer-guide/img/bastion-console-audit-chain.png b/docs/developer-guide/img/bastion-console-audit-chain.png new file mode 100644 index 00000000000..683a27f844f Binary files /dev/null and b/docs/developer-guide/img/bastion-console-audit-chain.png differ diff --git a/docs/developer-guide/img/bastion-console-auditor.png b/docs/developer-guide/img/bastion-console-auditor.png new file mode 100644 index 00000000000..b49526e7cba Binary files /dev/null and b/docs/developer-guide/img/bastion-console-auditor.png differ diff --git a/docs/developer-guide/img/bastion-console-balance-sheet.png b/docs/developer-guide/img/bastion-console-balance-sheet.png new file mode 100644 index 00000000000..2ca5de4f8ef Binary files /dev/null and b/docs/developer-guide/img/bastion-console-balance-sheet.png differ diff --git a/docs/developer-guide/img/bastion-console-entry.png b/docs/developer-guide/img/bastion-console-entry.png new file mode 100644 index 00000000000..87ea3b55d73 Binary files /dev/null and b/docs/developer-guide/img/bastion-console-entry.png differ diff --git a/docs/developer-guide/img/bastion-console-evidence.png b/docs/developer-guide/img/bastion-console-evidence.png new file mode 100644 index 00000000000..f62214f732a Binary files /dev/null and b/docs/developer-guide/img/bastion-console-evidence.png differ diff --git a/docs/developer-guide/img/bastion-console-income-statement.png b/docs/developer-guide/img/bastion-console-income-statement.png new file mode 100644 index 00000000000..6807c9edf42 Binary files /dev/null and b/docs/developer-guide/img/bastion-console-income-statement.png differ diff --git a/docs/developer-guide/img/bastion-console-journal.png b/docs/developer-guide/img/bastion-console-journal.png new file mode 100644 index 00000000000..b134cf3ea45 Binary files /dev/null and b/docs/developer-guide/img/bastion-console-journal.png differ diff --git a/docs/developer-guide/img/bastion-console-overview.png b/docs/developer-guide/img/bastion-console-overview.png new file mode 100644 index 00000000000..28295446608 Binary files /dev/null and b/docs/developer-guide/img/bastion-console-overview.png differ diff --git a/docs/developer-guide/img/bastion-console-periods.png b/docs/developer-guide/img/bastion-console-periods.png new file mode 100644 index 00000000000..1e2d948bcf3 Binary files /dev/null and b/docs/developer-guide/img/bastion-console-periods.png differ diff --git a/docs/developer-guide/img/bastion-console-phone.png b/docs/developer-guide/img/bastion-console-phone.png new file mode 100644 index 00000000000..151b70e7048 Binary files /dev/null and b/docs/developer-guide/img/bastion-console-phone.png differ diff --git a/docs/developer-guide/img/bastion-console-reconciliation.png b/docs/developer-guide/img/bastion-console-reconciliation.png new file mode 100644 index 00000000000..3b7f4674d94 Binary files /dev/null and b/docs/developer-guide/img/bastion-console-reconciliation.png differ diff --git a/docs/developer-guide/img/bastion-console-t-account.png b/docs/developer-guide/img/bastion-console-t-account.png new file mode 100644 index 00000000000..eb6cede3fba Binary files /dev/null and b/docs/developer-guide/img/bastion-console-t-account.png differ diff --git a/docs/developer-guide/img/bastion-console-trial-balance.png b/docs/developer-guide/img/bastion-console-trial-balance.png new file mode 100644 index 00000000000..70189e66150 Binary files /dev/null and b/docs/developer-guide/img/bastion-console-trial-balance.png differ diff --git a/docs/developer-guide/img/bastion-dashboard-dark.png b/docs/developer-guide/img/bastion-dashboard-dark.png new file mode 100644 index 00000000000..35e59b33a0c Binary files /dev/null and b/docs/developer-guide/img/bastion-dashboard-dark.png differ diff --git a/docs/developer-guide/img/bastion-dashboard-unregistered.png b/docs/developer-guide/img/bastion-dashboard-unregistered.png new file mode 100644 index 00000000000..aa47631f284 Binary files /dev/null and b/docs/developer-guide/img/bastion-dashboard-unregistered.png differ diff --git a/docs/developer-guide/img/bastion-dashboard.png b/docs/developer-guide/img/bastion-dashboard.png new file mode 100644 index 00000000000..94d9aaee965 Binary files /dev/null and b/docs/developer-guide/img/bastion-dashboard.png differ diff --git a/docs/developer-guide/img/bastion-exchange.png b/docs/developer-guide/img/bastion-exchange.png new file mode 100644 index 00000000000..db86e01c5a1 Binary files /dev/null and b/docs/developer-guide/img/bastion-exchange.png differ diff --git a/docs/developer-guide/img/bastion-lock.png b/docs/developer-guide/img/bastion-lock.png new file mode 100644 index 00000000000..b94daf5f4d3 Binary files /dev/null and b/docs/developer-guide/img/bastion-lock.png differ diff --git a/docs/developer-guide/img/bastion-order-book.png b/docs/developer-guide/img/bastion-order-book.png new file mode 100644 index 00000000000..09bdf8ae1bd Binary files /dev/null and b/docs/developer-guide/img/bastion-order-book.png differ diff --git a/docs/developer-guide/img/bastion-order-filled.png b/docs/developer-guide/img/bastion-order-filled.png new file mode 100644 index 00000000000..da1ae9da1a9 Binary files /dev/null and b/docs/developer-guide/img/bastion-order-filled.png differ diff --git a/docs/developer-guide/img/bastion-payees.png b/docs/developer-guide/img/bastion-payees.png new file mode 100644 index 00000000000..dd3bfdbf104 Binary files /dev/null and b/docs/developer-guide/img/bastion-payees.png differ diff --git a/docs/developer-guide/img/bastion-receipt.png b/docs/developer-guide/img/bastion-receipt.png new file mode 100644 index 00000000000..ffa1cc459a8 Binary files /dev/null and b/docs/developer-guide/img/bastion-receipt.png differ diff --git a/docs/developer-guide/img/bastion-security-center.png b/docs/developer-guide/img/bastion-security-center.png new file mode 100644 index 00000000000..c07ecfb2d9c Binary files /dev/null and b/docs/developer-guide/img/bastion-security-center.png differ diff --git a/docs/developer-guide/img/bastion-settings.png b/docs/developer-guide/img/bastion-settings.png new file mode 100644 index 00000000000..b16d9207126 Binary files /dev/null and b/docs/developer-guide/img/bastion-settings.png differ diff --git a/docs/developer-guide/img/bastion-sign-in-code.png b/docs/developer-guide/img/bastion-sign-in-code.png new file mode 100644 index 00000000000..0750e8e4251 Binary files /dev/null and b/docs/developer-guide/img/bastion-sign-in-code.png differ diff --git a/docs/developer-guide/img/bastion-sign-in-flow.svg b/docs/developer-guide/img/bastion-sign-in-flow.svg new file mode 100644 index 00000000000..9d6d0de317a --- /dev/null +++ b/docs/developer-guide/img/bastion-sign-in-flow.svg @@ -0,0 +1,45 @@ + + + + Signing in: password, code, PKCE -- or a passkey + The app never sees a token it did not ask for with its own code_verifier. Each access token's amr claim says how the + sign-in was made, and the server reads it when the device registers its signing key. + + App + net/Session.java + + + Server + SecurityConfig.java + + + 1. POST /login e-mail + password + SignInGuard counts failures per account + + 2. 302 /login/mfa + the account has an authenticator app + + 3. POST /login/mfa six-digit code + TOTP, or one recovery code + + 4. GET /oauth2/authorize code_challenge (S256) + verifier is 32 random bytes kept in memory + + 5. 302 ?code=... (in a browser: /signin/code) + single-use authorization code + + 6. POST /oauth2/token code + code_verifier + server hashes the verifier and compares + + 7. access token (5 min), refresh token (12 h) + amr: pwd, otp, mfa + + Or, with a passkey, steps 1-3 become: + + 1' GET /webauthn/authenticate/options + a challenge for this sign-in + + 2' POST /login/webauthn signed assertion + the platform authenticator asks for a face, finger or PIN + then steps 4-7 as above, with amr: hwk -- enough to register a device without the password + diff --git a/docs/developer-guide/img/bastion-sign-in-passkey-required.png b/docs/developer-guide/img/bastion-sign-in-passkey-required.png new file mode 100644 index 00000000000..54f4bb1640d Binary files /dev/null and b/docs/developer-guide/img/bastion-sign-in-passkey-required.png differ diff --git a/docs/developer-guide/img/bastion-sign-in.png b/docs/developer-guide/img/bastion-sign-in.png new file mode 100644 index 00000000000..880b6900dc5 Binary files /dev/null and b/docs/developer-guide/img/bastion-sign-in.png differ diff --git a/docs/developer-guide/img/bastion-signed-request.svg b/docs/developer-guide/img/bastion-signed-request.svg new file mode 100644 index 00000000000..85c3660cb51 --- /dev/null +++ b/docs/developer-guide/img/bastion-signed-request.svg @@ -0,0 +1,60 @@ + + + + A signed payment, and what the server checks before money moves + Every call that sends money away -- a transfer, a new payee, a withdrawal, a new coin address -- carries the + Bastion-Signature header. The checks run in this order; the first that fails answers with a coded refusal. + + In the app + 1. Unlock opens the app key; the app key unseals the + device's P-256 private key (security/Keys, DeviceKey) + 2. Canonical form of the exact bytes it will send: + POST|/api/transfers|<body sha256>|<idem key>|<nonce>|<ts> + 3. SHA256withECDSA over it (security/RequestSigner) + 4. Header, beside the Idempotency-Key it signs: + Bastion-Signature: v1;device=..;ts=..;nonce=..;sig=.. + + + 1 Bearer token + valid, not expired, not revoked by a password change + + + 2 Signature + device registered to this customer, ECDSA verifies over the canonical form + + + 3 Freshness + timestamp within the window; nonce never seen -- recorded in its own transaction + + + 4 Device policy + money leaving the customer needs a trusted device: not rooted, attested + + + 5 Step-up + own-account moves above bastion.stepup.threshold must be signed too + + + 6 Limits + per transfer, per day; a payee in cooling-off gets only a small amount + + + 7 Idempotency + same key + same body answers the first entry; different body is refused + + + 8 Ledger + one journal entry, conditional balance updates, accounts locked in id order + + Where each check lives + money/RequestSigning.java signature, nonce, timestamp + money/Devices.java registration, trust + money/Money.java policy, step-up, limits + money/Payees.java cooling-off + ledger/Ledger.java idempotency, the entry + + A refusal + 403 (device-not-attested) This device is not attested... + The code is stable; the app shows error.<code> from its + own bundle in the customer's language (net/Net.localize). + diff --git a/docs/developer-guide/img/bastion-statement.png b/docs/developer-guide/img/bastion-statement.png new file mode 100644 index 00000000000..b7c8ebe13b0 Binary files /dev/null and b/docs/developer-guide/img/bastion-statement.png differ diff --git a/docs/developer-guide/img/bastion-totp-enrolment.png b/docs/developer-guide/img/bastion-totp-enrolment.png new file mode 100644 index 00000000000..34e8903abb4 Binary files /dev/null and b/docs/developer-guide/img/bastion-totp-enrolment.png differ diff --git a/docs/developer-guide/img/bastion-trade-entry.svg b/docs/developer-guide/img/bastion-trade-entry.svg new file mode 100644 index 00000000000..81117ff155a --- /dev/null +++ b/docs/developer-guide/img/bastion-trade-entry.svg @@ -0,0 +1,50 @@ + + + + One trade is one journal entry + A buyer's limit order for 0.01 BTC at 60,000.00 USD meets a resting sell. Both holds were set aside when the orders + were placed; the fill pays out of them, so a customer can never spend what an open order has reserved. + + Account + Asset + Posting + Why + buyer HOLD + USD + -601.20 + ceil(p x q) once per order, plus the taker's 0.20% fee + + seller CHECKING + USD + +600.00 + floor(p x q) once per order; the maker pays no fee + + SYS_FEES + USD + +1.20 + the fee, plus whatever rounding left between the sides + + seller HOLD + BTC + -0.01000000 + the coins the sell order set aside + + buyer WALLET + BTC + +0.01000000 + the coins bought + + Sum per asset: USD -601.20 + 600.00 + 1.20 = 0 BTC -0.01 + 0.01 = 0 + + Why it cannot be half done + Ledger.post writes every posting in one transaction, + each balance with UPDATE ... WHERE balance + delta >= 0, + in account-id order, and rolls back on any exception. + The market row is locked first: one match at a time. + + Where it lives + exchange/Exchange.java matching, holds, rounding + ledger/Ledger.java the entry and its legs + admin/ verify: every entry sums to + zero, balances = postings + diff --git a/docs/developer-guide/img/bastion-transaction.png b/docs/developer-guide/img/bastion-transaction.png new file mode 100644 index 00000000000..61ba35df314 Binary files /dev/null and b/docs/developer-guide/img/bastion-transaction.png differ diff --git a/docs/developer-guide/img/bastion-transfer-confirm.png b/docs/developer-guide/img/bastion-transfer-confirm.png new file mode 100644 index 00000000000..5673d057273 Binary files /dev/null and b/docs/developer-guide/img/bastion-transfer-confirm.png differ diff --git a/docs/developer-guide/img/bastion-transfer.png b/docs/developer-guide/img/bastion-transfer.png new file mode 100644 index 00000000000..c34daec9ab0 Binary files /dev/null and b/docs/developer-guide/img/bastion-transfer.png differ diff --git a/docs/developer-guide/img/bastion-withdraw.png b/docs/developer-guide/img/bastion-withdraw.png new file mode 100644 index 00000000000..aa79f879254 Binary files /dev/null and b/docs/developer-guide/img/bastion-withdraw.png differ diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index ea56f19762e..e7ce1f0c8f6 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -821,6 +821,12 @@ aarch64 Parley Savor Wayline +# The sample application the Bastion chapters walk through, and the units its ledger keeps coins in. +Bastion +satoshi +gwei +wei +cleartext Lousberg Elda prekey @@ -830,4 +836,5 @@ callee signalling coturn unconfigured +Sepolia ntfy diff --git a/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt b/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt index 15ec20ff5d8..fd4e94ab76f 100644 --- a/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt +++ b/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt @@ -20,3 +20,4 @@ pseudonymous Plausible PostHog Piwik +Sepolia diff --git a/docs/website/assets/css/extended/cn1-demos.css b/docs/website/assets/css/extended/cn1-demos.css index 5a736516285..e51000b97b2 100644 --- a/docs/website/assets/css/extended/cn1-demos.css +++ b/docs/website/assets/css/extended/cn1-demos.css @@ -170,7 +170,7 @@ body.dark .cn1-demo-card__thumb img { } } -/* Full-stack samples (Savor, Wayline, Parley): the head of the demos page, and a +/* Full-stack samples (Savor, Wayline, Parley, Bastion): the head of the demos page, and a compact banner on every individual demo page. */ .post-single--demo .cn1-flagship { --cn1-demos-card: var(--cn1-demo-card); diff --git a/docs/website/content/bastion.md b/docs/website/content/bastion.md new file mode 100644 index 00000000000..0036a03cb9c --- /dev/null +++ b/docs/website/content/bastion.md @@ -0,0 +1,49 @@ +--- +title: "Build a Secure Banking App in Java with Bastion" +description: "Bastion is a security-first banking and crypto-exchange application: accounts, device-signed transfers, cards, an order book, passkeys and a Java backend with a double-entry ledger. Free MIT-0 source." +url: "/bastion/" +summary: "A complete full-stack banking application whose every protection is visible and explained." +images: ["/developer-guide/img/bastion-dashboard.png"] +ShowToc: true +--- + +Bastion is a complete, full-stack virtual bank in Java. Customers open accounts in several currencies and coins, move money between their own accounts and to other people, order and freeze cards, and trade bitcoin and ether on an order book; staff run the bank's books from a desktop console: journal, general ledger, trial balance, balance sheet, income statement, reconciliation, period close, four-eyes approvals and the audit chain. Every security measure it takes is listed with its real state in the app's Security Center. Use it to build your own banking, wallet or exchange app with Codename One. + +**[Generate your project](/initializr/)** · **[Read the user's guide](/developer-guide/bastion-users-guide/)** · **[Browse the source](https://github.com/codenameone/CodenameOne/tree/master/scripts/bastion)** + +In the Initializr, choose the **Bastion** banking template, set your application name and package, and generate the project. The application source is licensed under [MIT-0](https://github.com/codenameone/CodenameOne/blob/master/scripts/bastion/LICENSE): you can modify it, use it commercially and keep your changes private without royalties. + +## What comes with the application? + +| Part | Included workflows | +| --- | --- | +| Customer | Open an account with an authenticator app and a passkey, see balances and history, move money and pay others, manage payees, order and freeze cards, trade on the exchange, send coins to an allow-listed address | +| Security | Sign-in with PKCE and a second factor or a passkey, payments signed by a key on the device, an app lock with biometrics or a PIN, secure screens, certificate pinning and a Security Center that reports each protection honestly | +| Back office | A desktop console for administrators and read-only auditors: the journal with debit and credit legs, T-accounts, the trial balance, balance sheet and income statement, reconciliation with breaks highlighted, period close, maker-checker approvals, audit chain verification, staff access log and evidence export | +| Java backend | An authorization server, a double-entry ledger with idempotent transfers, limits and cooling-off periods, an order-matching engine and the audit chain | +| Shared code | API contracts, data classes and the signed-request format shared between the client and server | + +The same client project targets iOS, Android, desktop and the web, and the server runs on SQLite, PostgreSQL, MySQL, and MariaDB. + +![Bastion dashboard](/developer-guide/img/bastion-dashboard.png) +![Bastion order book](/developer-guide/img/bastion-order-book.png) +![Bastion Security Center](/developer-guide/img/bastion-security-center.png) +![Bastion back-office console](/developer-guide/img/bastion-console-overview.png) +![Bastion payment confirmation](/developer-guide/img/bastion-transfer-confirm.png) +![Bastion journal](/developer-guide/img/bastion-console-journal.png) + +## From first run to customization + +1. **[Run the project](https://github.com/codenameone/CodenameOne/tree/master/scripts/bastion#running-it).** The development profile needs nothing installed: an in-memory database, demo accounts and a simulated market to trade against. +2. **[Try every screen](/developer-guide/bastion-users-guide/).** The user's guide walks through opening an account, payments, cards, the exchange, the lock, the Security Center and the administrator's checks. +3. **[Customize and deploy](/developer-guide/bastion-implementors-guide/).** The implementor's guide explains the sign-in, signed payments, the ledger and the audit chain, and lists every security feature, setting and endpoint with its place in the code. + +## What does a live bank still need? + +Bastion supplies the connected application and its source, not a banking license or the systems around it. No money in it is real: deposits are a counter, cards spend nowhere, coins never touch a blockchain and the market is simulated. Device integrity checking is simulated on the server, and code hardening and attestation tokens take effect only in builds from the Codename One build server. + +A real service also needs identity verification, payment rails, keys in a hardware security module, custody for coins, regulatory reporting and its own security review. The implementor's guide lists that work. + +## Looking for a different starting point? + +[Savor](/savor/) is a food-delivery marketplace and [Wayline](/wayline/) a ride-hailing service, both on the same architecture. diff --git a/docs/website/layouts/partials/cn1-flagship-samples.html b/docs/website/layouts/partials/cn1-flagship-samples.html index ad97062fc62..801cf6f64e6 100644 --- a/docs/website/layouts/partials/cn1-flagship-samples.html +++ b/docs/website/layouts/partials/cn1-flagship-samples.html @@ -36,6 +36,14 @@ "images" (slice "/developer-guide/img/skirmish-home.png" "/developer-guide/img/skirmish-battle.png" "/developer-guide/img/skirmish-finale.png") "guide" "/developer-guide/skirmish-implementors-guide/" "source" "https://github.com/codenameone/CodenameOne/tree/master/scripts/skirmish") + (dict + "name" "Bastion" + "kind" "Banking and crypto exchange" + "url" "/bastion/" + "summary" "A security-first bank with a Java backend: accounts, transfers signed by the device, cards, an exchange with an order book, passkeys and a Security Center." + "images" (slice "/developer-guide/img/bastion-dashboard.png" "/developer-guide/img/bastion-order-book.png" "/developer-guide/img/bastion-security-center.png") + "guide" "/developer-guide/bastion-users-guide/" + "source" "https://github.com/codenameone/CodenameOne/tree/master/scripts/bastion") -}}
diff --git a/maven/backend/src/test/java/com/codename1/backend/security/AuthorizationServerTest.java b/maven/backend/src/test/java/com/codename1/backend/security/AuthorizationServerTest.java index 1960b930d69..b108a122308 100644 --- a/maven/backend/src/test/java/com/codename1/backend/security/AuthorizationServerTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/security/AuthorizationServerTest.java @@ -50,6 +50,7 @@ import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerSettings; import com.codename1.backend.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService; import com.codename1.backend.security.oauth2.server.authorization.InMemoryRegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationServer; import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; import com.codename1.backend.security.oauth2.server.authorization.OAuth2TokenContext; import com.codename1.backend.security.oauth2.server.authorization.RegisteredClient; @@ -131,6 +132,25 @@ void statelessCredentialsCannotAuthorizeUserGrants(boolean apiKey) throws Except } } + @Test + void authenticationMethodsNameHowTheUserSignedIn() { + java.util.List none = java.util.Collections.emptyList(); + Authentication password = UsernamePasswordAuthenticationToken.authenticated( + new User("ada", "", none), null, none); + assertEquals("pwd", OAuth2AuthorizationServer.authMethods(password, false)); + assertEquals("pwd otp mfa", OAuth2AuthorizationServer.authMethods(password, true)); + com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntity ada = + new com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntity( + "ada", new byte[] {1, 2, 3}, "Ada"); + assertEquals("hwk user mfa", OAuth2AuthorizationServer.authMethods( + new com.codename1.backend.security.webauthn.WebAuthnAuthentication( + ada, none, new byte[] {9}, true), false)); + assertEquals("hwk", OAuth2AuthorizationServer.authMethods( + new com.codename1.backend.security.webauthn.WebAuthnAuthentication( + ada, none, new byte[] {9}, false), false)); + assertNull(OAuth2AuthorizationServer.authMethods(null, true)); + } + @Test void registeredScopesCannotChangeWhenSerialized() { for (String scope : new String[] {"read,admin", "read\tadmin"}) { @@ -582,6 +602,8 @@ void authorizationCode() throws Exception { assertEquals("app", id.getClaimAsString("azp")); assertEquals("nonce-7", id.getClaimAsString("nonce")); assertEquals(before, id.getClaimAsLong("auth_time").longValue()); + // How: a password and nothing else. + assertEquals(Arrays.asList("pwd"), id.getClaimAsStringList("amr")); assertEquals("ada", id.getClaimAsString("preferred_username")); // at_hash: the left half of the access token's SHA-256. byte[] digest = com.codename1.backend.Crypto.sha256( diff --git a/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 9b4138760d8..34021ce4db9 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -2646,6 +2646,12 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc if (playServicesAds) { minSDK = maxInt("21", minSDK); } + if (playIntegrity) { + // com.google.android.play:integrity declares minSdk 21 in its own manifest, so + // an application left at the default floor of 19 failed the manifest merger + // ("uses-sdk:minSdkVersion 19 cannot be smaller than version 21"). + minSDK = maxInt("21", minSDK); + } if (googleAdUnitId != null && googleAdUnitId.length() > 0) { minSDK = maxInt("9", minSDK); if (!userXapplication.contains("com.google.android.gms.version")) { diff --git a/maven/core-unittests/src/test/java/com/codename1/security/CipherSignatureTest.java b/maven/core-unittests/src/test/java/com/codename1/security/CipherSignatureTest.java index ca6f3a17101..b66fc11c11c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/security/CipherSignatureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/security/CipherSignatureTest.java @@ -1,6 +1,24 @@ /* - * Copyright (c) 2008-2026, Codename One and/or its affiliates. All rights reserved. - * Distributed under the same terms as the rest of Codename One. + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.security; @@ -76,6 +94,70 @@ void rsaSignAndVerify() { assertFalse(Signature.verify(Signature.SHA256_WITH_RSA, kp.getPublicKey(), data, sig)); } + @Test + void ecSignAndVerify() { + KeyPair kp = KeyGenerator.ec(256); + assertEquals(PublicKey.EC, kp.getPublicKey().getAlgorithm()); + assertEquals(PublicKey.EC, kp.getPrivateKey().getAlgorithm()); + // SubjectPublicKeyInfo for P-256 is a fixed 26-byte header plus the + // 65-byte uncompressed point. + assertEquals(91, kp.getPublicKey().getEncoded().length); + byte[] data = "ledger transfer".getBytes(); + byte[] sig = Signature.sign(Signature.SHA256_WITH_ECDSA, kp.getPrivateKey(), data); + assertEquals(0x30, sig[0] & 0xff, "ECDSA signatures are DER on every port"); + assertTrue(Signature.verify(Signature.SHA256_WITH_ECDSA, kp.getPublicKey(), data, sig)); + data[0] ^= 0x01; + assertFalse(Signature.verify(Signature.SHA256_WITH_ECDSA, kp.getPublicKey(), data, sig)); + KeyPair other = KeyGenerator.ec(256); + data[0] ^= 0x01; + assertFalse(Signature.verify(Signature.SHA256_WITH_ECDSA, other.getPublicKey(), data, sig)); + } + + /// java.security omits the optional public point from an EC PKCS#8 key; + /// the iOS port cannot import such a key, so the signing form adds it. + @Test + void ecPkcs8GainsItsPublicPoint() throws Exception { + java.security.KeyPairGenerator g = java.security.KeyPairGenerator.getInstance("EC"); + g.initialize(new java.security.spec.ECGenParameterSpec("secp256r1")); + for (int i = 0; i < 5; i++) { + java.security.KeyPair jkp = g.generateKeyPair(); + byte[] pkcs8 = jkp.getPrivate().getEncoded(); + byte[] completed = EcKeys.withPublicPoint(pkcs8); + assertEquals(138, completed.length); + assertSame(completed, EcKeys.withPublicPoint(completed)); + java.security.KeyFactory kf = java.security.KeyFactory.getInstance("EC"); + java.security.interfaces.ECPrivateKey back = (java.security.interfaces.ECPrivateKey) + kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(completed)); + assertEquals(((java.security.interfaces.ECPrivateKey) jkp.getPrivate()).getS(), back.getS()); + byte[] spki = jkp.getPublic().getEncoded(); + byte[] point = new byte[65]; + System.arraycopy(spki, 26, point, 0, 65); + byte[] embedded = new byte[65]; + System.arraycopy(completed, 73, embedded, 0, 65); + assertArrayEquals(point, embedded); + + PrivateKey key = PrivateKey.fromPkcs8(PublicKey.EC, pkcs8); + byte[] data = "x".getBytes(); + byte[] sig = Signature.sign(Signature.SHA256_WITH_ECDSA, key, data); + assertTrue(Signature.verify(Signature.SHA256_WITH_ECDSA, + PublicKey.fromX509(PublicKey.EC, spki), data, sig)); + } + } + + @Test + void jwtEs256RoundTrip() { + KeyPair kp = KeyGenerator.ec(256); + Map claims = new LinkedHashMap(); + claims.put("sub", "alice"); + String token = Jwt.sign(claims, kp.getPrivateKey(), Jwt.ES256); + Jwt parsed = Jwt.parse(token); + assertEquals("ES256", parsed.getAlgorithm()); + // JOSE R||S form: 64 bytes, base64url without padding is 86 chars. + assertEquals(86, token.substring(token.lastIndexOf('.') + 1).length()); + assertTrue(parsed.verify(kp.getPublicKey())); + assertFalse(parsed.verify(KeyGenerator.ec(256).getPublicKey())); + } + @Test void jwtRsRoundTrip() { KeyPair kp = KeyGenerator.rsa(2048); diff --git a/maven/core-unittests/src/test/java/com/codename1/security/EcKeysLadderTest.java b/maven/core-unittests/src/test/java/com/codename1/security/EcKeysLadderTest.java new file mode 100644 index 00000000000..440fb88e4cc --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/security/EcKeysLadderTest.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +import com.codename1.util.BigInteger; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// The public point EcKeys derives, against the JDK's, for random keys and +/// for the scalars at the edges of the ladder. +class EcKeysLadderTest { + private static final String N = + "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"; + private static final String P = + "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff"; + private static final String GX = + "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296"; + private static final String GY = + "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5"; + + @Test + void matchesTheJdkForRandomKeys() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(new ECGenParameterSpec("secp256r1")); + for (int iter = 0; iter < 16; iter++) { + KeyPair kp = kpg.generateKeyPair(); + java.math.BigInteger d = ((ECPrivateKey) kp.getPrivate()).getS(); + java.security.spec.ECPoint w = ((ECPublicKey) kp.getPublic()).getW(); + BigInteger[] q = EcKeys.multiply(new BigInteger(d.toString(16), 16)); + assertNotNull(q); + assertEquals(w.getAffineX().toString(16), q[0].toString(16)); + assertEquals(w.getAffineY().toString(16), q[1].toString(16)); + } + } + + @Test + void handlesTheEdgesOfTheScalarRange() { + BigInteger[] one = EcKeys.multiply(BigInteger.ONE); + assertEquals(GX, pad(one[0])); + assertEquals(GY, pad(one[1])); + BigInteger n = new BigInteger(N, 16); + BigInteger[] minusOne = EcKeys.multiply(n.subtract(BigInteger.ONE)); + assertEquals(GX, pad(minusOne[0])); + assertEquals(pad(new BigInteger(P, 16).subtract(new BigInteger(GY, 16))), + pad(minusOne[1])); + assertNull(EcKeys.multiply(n), "n * G is the point at infinity"); + assertNull(EcKeys.multiply(BigInteger.ZERO), "zero is no private key"); + assertNull(EcKeys.multiply(n.add(BigInteger.ONE)), "a scalar past n is refused"); + assertNull(EcKeys.multiply(BigInteger.ONE.negate()), "a negative scalar is refused"); + // 2G and 3G = 2G + G, which an affine ladder handles as special cases. + BigInteger[] two = EcKeys.multiply(BigInteger.valueOf(2)); + assertEquals("7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978", + pad(two[0])); + BigInteger[] three = EcKeys.multiply(BigInteger.valueOf(3)); + assertEquals("5ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c", + pad(three[0])); + } + + private static String pad(BigInteger v) { + String hex = v.toString(16); + while (hex.length() < 64) { + hex = "0" + hex; + } + return hex; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/security/HashTest.java b/maven/core-unittests/src/test/java/com/codename1/security/HashTest.java index 38bbd771055..62290c458c3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/security/HashTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/security/HashTest.java @@ -1,6 +1,24 @@ /* * Copyright (c) 2008-2026, Codename One and/or its affiliates. All rights reserved. - * Distributed under the same terms as the rest of Codename One. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.security; @@ -81,4 +99,74 @@ void hexRoundTrip() { byte[] random = new byte[] { (byte)0x00, (byte)0x7f, (byte)0x80, (byte)0xff, 0x12, 0x34 }; assertArrayEquals(random, Hash.fromHex(Hash.toHex(random))); } + + // ---------------------------------------------------------------- + // Keccak-256 (Ethereum) and SHA3-256 (FIPS 202) share the sponge and + // differ only in the padding byte, so each gets its own known answers. + + @Test + void keccak256_emptyAndShort() { + // the Keccak-256 of the empty string every Ethereum client prints + assertEquals("c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + Hash.toHex(Hash.keccak256(new byte[0]))); + assertEquals("4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45", + Hash.toHex(Hash.keccak256(ascii("abc")))); + assertEquals("5f16f4c7f149ac4f9510d9cf8cf384038ad348b3bcdc01915f95de12df9d1b02", + Hash.toHex(Hash.keccak256(ascii("testing")))); + assertEquals("4d741b6f1eb29cb2a9b9911c82f56fa8d73b04959d3d9d222895df6c0b28aa15", + Hash.toHex(Hash.keccak256(ascii("The quick brown fox jumps over the lazy dog")))); + } + + @Test + void keccak256_isNotSha3() { + // FIPS 202 appendix vectors: the 0x06 domain byte changes everything + assertEquals("a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a", + Hash.toHex(Hash.create(Hash.SHA3_256).digest(new byte[0]))); + assertEquals("3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532", + Hash.toHex(Hash.create(Hash.SHA3_256).digest(ascii("abc")))); + assertEquals(32, Hash.create(Hash.KECCAK256).digestLength()); + } + + @Test + void sha3_256_millionA_multiBlock() { + byte[] data = new byte[1_000_000]; + for (int i = 0; i < data.length; i++) data[i] = 'a'; + // NIST SHA3-256("a" * 1_000_000): over seven thousand sponge blocks + assertEquals("5c8875ae474a3634ba4fd55ec85bffd661f32aca75c6d699d0cdcb6c115891c1", + Hash.toHex(Hash.create(Hash.SHA3_256).digest(data))); + } + + @Test + void keccak256_streamingMatchesOneShotAcrossBlockBoundaries() { + // 1000 bytes is seven full 136-byte blocks and a partial one; feed it + // in chunk sizes that straddle every boundary + byte[] data = new byte[1000]; + for (int i = 0; i < data.length; i++) data[i] = (byte) (i * 7 + 3); + byte[] expected = Hash.keccak256(data); + for (int chunk : new int[] { 1, 5, 135, 136, 137, 300 }) { + Hash h = Hash.create(Hash.KECCAK256); + for (int off = 0; off < data.length; off += chunk) { + h.update(data, off, Math.min(chunk, data.length - off)); + } + assertArrayEquals(expected, h.digest(), "chunk " + chunk); + } + Hash byteAtATime = Hash.create(Hash.KECCAK256); + for (byte b : data) byteAtATime.update(b); + assertArrayEquals(expected, byteAtATime.digest()); + } + + @Test + void hmacKeccak256_usesTheSpongeRate() { + // HMAC over a sponge pads the key to the rate (136 bytes); a key of + // exactly that length is used as it is, one byte longer is hashed first + byte[] key136 = new byte[136]; + byte[] key137 = new byte[137]; + for (int i = 0; i < key137.length; i++) key137[i] = (byte) i; + System.arraycopy(key137, 0, key136, 0, 136); + byte[] msg = ascii("message"); + byte[] direct = Hmac.create(Hash.KECCAK256, key136).doFinal(msg); + byte[] viaHashedKey = Hmac.create(Hash.KECCAK256, Hash.keccak256(key137)).doFinal(msg); + assertArrayEquals(viaHashedKey, Hmac.create(Hash.KECCAK256, key137).doFinal(msg)); + assertFalse(java.util.Arrays.equals(direct, viaHashedKey)); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/security/Secp256k1Test.java b/maven/core-unittests/src/test/java/com/codename1/security/Secp256k1Test.java new file mode 100644 index 00000000000..886efb8a0a3 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/security/Secp256k1Test.java @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2008-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/// Known answers for secp256k1: the generator multiples every reference lists, +/// the RFC 6979 vectors the Bitcoin test suites share (private key, message +/// hashed once with SHA-256, compact r || s), and the address Ethereum +/// derives from a published test key. The rest checks the algebra against +/// itself: recover(sign(h)) is the key, a tampered signature fails, the +/// high-S form is rejected and normalised, and BIP32-style tweaks agree on +/// both sides of the curve. +class Secp256k1Test { + + private static final String GX = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + private static final String GY = "483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8"; + private static final String N_MINUS_1 = + "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140"; + + private static byte[] hex(String s) { + return Hash.fromHex(s); + } + + private static String hex(byte[] b) { + return Hash.toHex(b); + } + + private static byte[] key(long v) { + byte[] k = new byte[32]; + for (int i = 0; i < 8; i++) { + k[31 - i] = (byte) (v >>> (8 * i)); + } + return k; + } + + private static byte[] ascii(String s) { + try { + return s.getBytes("US-ASCII"); + } catch (java.io.UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + // ---------------------------------------------------------------- + // keys + + @Test + void publicKeyOfOneIsTheGenerator() { + assertEquals("04" + GX + GY, hex(Secp256k1.publicKey(key(1), false))); + assertEquals("02" + GX, hex(Secp256k1.publicKey(key(1), true))); + } + + @Test + void publicKeyOfTwoAndThree() { + assertEquals("04c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" + + "1ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52a", + hex(Secp256k1.publicKey(key(2), false))); + assertEquals("04f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + + "388f7b0f632de8140fe337e62a37f3566500a99934c2231b6cb9fd7584b8e672", + hex(Secp256k1.publicKey(key(3), false))); + } + + @Test + void publicKeyOfNMinusOneIsMinusG() { + // -G has G's x and p - G.y, which is odd, so the compressed prefix is 03 + assertEquals("04" + GX + "b7c52588d95c3b9aa25b0403f1eef75702e84bb7597aabe663b82f6f04ef2777", + hex(Secp256k1.publicKey(hex(N_MINUS_1), false))); + assertEquals("03" + GX, hex(Secp256k1.publicKey(hex(N_MINUS_1), true))); + } + + @Test + void ethereumAddressOfPublishedTestKey() { + byte[] priv = hex("4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"); + byte[] pub = Secp256k1.publicKey(priv, false); + assertEquals(65, pub.length); + byte[] xy = new byte[64]; + System.arraycopy(pub, 1, xy, 0, 64); + byte[] digest = Hash.keccak256(xy); + byte[] address = new byte[20]; + System.arraycopy(digest, 12, address, 0, 20); + assertEquals("2c7536e3605d9c16a7a3d7b1898e529396a65c23", hex(address)); + } + + @Test + void privateKeyValidity() { + assertFalse(Secp256k1.isValidPrivateKey(null)); + assertFalse(Secp256k1.isValidPrivateKey(new byte[31])); + assertFalse(Secp256k1.isValidPrivateKey(new byte[32])); + assertTrue(Secp256k1.isValidPrivateKey(key(1))); + assertTrue(Secp256k1.isValidPrivateKey(hex(N_MINUS_1))); + assertFalse(Secp256k1.isValidPrivateKey( + hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"))); + assertFalse(Secp256k1.isValidPrivateKey( + hex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"))); + assertThrows(CryptoException.class, () -> Secp256k1.publicKey(new byte[32], true)); + assertThrows(CryptoException.class, () -> Secp256k1.publicKey( + hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"), true)); + assertThrows(CryptoException.class, () -> Secp256k1.sign(new byte[32], new byte[32])); + } + + @Test + void publicKeyParsingAndConversion() { + byte[] priv = hex("4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"); + byte[] full = Secp256k1.publicKey(priv, false); + byte[] compressed = Secp256k1.publicKey(priv, true); + assertTrue(Secp256k1.isValidPublicKey(full)); + assertTrue(Secp256k1.isValidPublicKey(compressed)); + assertArrayEquals(compressed, Secp256k1.convertPublicKey(full, true)); + assertArrayEquals(full, Secp256k1.convertPublicKey(compressed, false)); + + // a point off the curve is refused in both forms + byte[] offCurve = full.clone(); + offCurve[64] ^= 1; + assertFalse(Secp256k1.isValidPublicKey(offCurve)); + assertThrows(CryptoException.class, () -> Secp256k1.convertPublicKey(offCurve, true)); + byte[] noSuchX = compressed.clone(); + noSuchX[0] = 2; + for (int i = 1; i < 33; i++) { + noSuchX[i] = 0; + } + noSuchX[32] = 5; // x = 5: 5^3 + 7 = 132 is not a square mod p + assertFalse(Secp256k1.isValidPublicKey(noSuchX)); + assertFalse(Secp256k1.isValidPublicKey(null)); + assertFalse(Secp256k1.isValidPublicKey(new byte[33])); + assertFalse(Secp256k1.isValidPublicKey(new byte[64])); + } + + // ---------------------------------------------------------------- + // RFC 6979 vectors (secp256k1, SHA-256, from the Bitcoin test suites) + + private static void checkRfc6979(String privHex, String message, String rs) { + byte[] priv = hex(privHex); + byte[] hash = Hash.sha256(ascii(message)); + Secp256k1.EcdsaSignature sig = Secp256k1.sign(hash, priv); + assertEquals(rs, hex(sig.toCompact()), message); + assertTrue(sig.isLowS()); + byte[] pub = Secp256k1.publicKey(priv, true); + assertTrue(Secp256k1.verify(hash, sig, pub), message); + assertArrayEquals(pub, Secp256k1.recoverPublicKey(hash, sig, true), message); + } + + @Test + void rfc6979_keyOne_satoshiNakamoto() { + checkRfc6979("0000000000000000000000000000000000000000000000000000000000000001", + "Satoshi Nakamoto", + "934b1ea10a4b3c1757e2b0c017d0b6143ce3c9a7e6a4a49860d7a6ab210ee3d8" + + "2442ce9d2b916064108014783e923ec36b49743e2ffa1c4496f01a512aafd9e5"); + } + + @Test + void rfc6979_keyNMinusOne_satoshiNakamoto() { + checkRfc6979(N_MINUS_1, + "Satoshi Nakamoto", + "fd567d121db66e382991534ada77a6bd3106f0a1098c231e47993447cd6af2d0" + + "6b39cd0eb1bc8603e159ef5c20a5c8ad685a45b06ce9bebed3f153d10d93bed5"); + } + + @Test + void rfc6979_keyOne_bladeRunner() { + checkRfc6979("0000000000000000000000000000000000000000000000000000000000000001", + "All those moments will be lost in time, like tears in rain. Time to die...", + "8600dbd41e348fe5c9465ab92d23e3db8b98b873beecd930736488696438cb6b" + + "547fe64427496db33bf66019dacbf0039c04199abb0122918601db38a72cfc21"); + } + + @Test + void rfc6979_keyOne_einstein() { + checkRfc6979("0000000000000000000000000000000000000000000000000000000000000001", + "Everything should be made as simple as possible, but not simpler.", + "33a69cd2065432a30f3d1ce4eb0d59b8ab58c74f27c41a7fdb5696ad4e6108c9" + + "6f807982866f785d3f6418d24163ddae117b7db4d5fdf0071de069fa54342262"); + } + + @Test + void rfc6979_alanTuring() { + checkRfc6979("f8b8af8ce3c7cca5e300d33939540c10d45ce001b8f252bfbc57ba0342904181", + "Alan Turing", + "7063ae83e7f62bbb171798131b4a0564b956930092b33b07b395615d9ec7e15c" + + "58dfcc1e00a35e1572f366ffe34ba0fc47db1e7189759b9fb233c5b05ab388ea"); + } + + // ---------------------------------------------------------------- + // sign / verify / recover against each other + + @Test + void signIsDeterministicAndRecoverable() { + byte[] priv = hex("4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"); + byte[] pub = Secp256k1.publicKey(priv, false); + for (int i = 0; i < 8; i++) { + byte[] hash = Hash.keccak256(new byte[] { (byte) i, 42, (byte) (i * 31) }); + Secp256k1.EcdsaSignature sig = Secp256k1.sign(hash, priv); + assertArrayEquals(sig.toCompact(), Secp256k1.sign(hash, priv).toCompact()); + assertEquals(sig.recoveryId(), Secp256k1.sign(hash, priv).recoveryId()); + assertTrue(sig.recoveryId() == 0 || sig.recoveryId() == 1); + assertTrue(sig.isLowS()); + assertTrue(Secp256k1.verify(hash, sig, pub)); + assertTrue(Secp256k1.verify(hash, sig, Secp256k1.convertPublicKey(pub, true))); + assertArrayEquals(pub, Secp256k1.recoverPublicKey(hash, sig, false)); + // the other recovery id names the other candidate key, not ours + Secp256k1.EcdsaSignature other = new Secp256k1.EcdsaSignature( + sig.r(), sig.s(), sig.recoveryId() ^ 1); + byte[] otherKey = Secp256k1.recoverPublicKey(hash, other, false); + if (otherKey != null) { + assertFalse(java.util.Arrays.equals(pub, otherKey)); + } + } + } + + @Test + void verifyRejectsTampering() { + byte[] priv = key(12345); + byte[] pub = Secp256k1.publicKey(priv, true); + byte[] hash = Hash.sha256(ascii("pay 1 ETH to Alice")); + Secp256k1.EcdsaSignature sig = Secp256k1.sign(hash, priv); + assertTrue(Secp256k1.verify(hash, sig, pub)); + + byte[] r = sig.r(); + r[31] ^= 1; + assertFalse(Secp256k1.verify(hash, new Secp256k1.EcdsaSignature(r, sig.s(), -1), pub)); + byte[] s = sig.s(); + s[0] ^= 1; + assertFalse(Secp256k1.verify(hash, new Secp256k1.EcdsaSignature(sig.r(), s, -1), pub)); + byte[] otherHash = Hash.sha256(ascii("pay 2 ETH to Alice")); + assertFalse(Secp256k1.verify(otherHash, sig, pub)); + assertFalse(Secp256k1.verify(hash, sig, Secp256k1.publicKey(key(12346), true))); + + // r or s outside [1, n-1] can never verify + assertFalse(Secp256k1.verify(hash, new Secp256k1.EcdsaSignature(new byte[32], sig.s(), -1), pub)); + assertFalse(Secp256k1.verify(hash, new Secp256k1.EcdsaSignature(sig.r(), new byte[32], -1), pub)); + assertFalse(Secp256k1.verify(hash, new Secp256k1.EcdsaSignature( + sig.r(), hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"), -1), pub)); + assertNull(Secp256k1.recoverPublicKey(hash, + new Secp256k1.EcdsaSignature(new byte[32], sig.s(), 0), true)); + + assertThrows(CryptoException.class, () -> Secp256k1.verify(new byte[31], sig, pub)); + assertThrows(CryptoException.class, () -> Secp256k1.verify(hash, sig, new byte[33])); + assertThrows(CryptoException.class, () -> Secp256k1.recoverPublicKey(hash, + new Secp256k1.EcdsaSignature(sig.r(), sig.s(), -1), true)); + } + + @Test + void highSIsRejectedByVerifyAcceptedByRecoverAndNormalised() { + byte[] priv = key(777); + byte[] pub = Secp256k1.publicKey(priv, true); + byte[] hash = Hash.sha256(ascii("high s")); + Secp256k1.EcdsaSignature low = Secp256k1.sign(hash, priv); + assertTrue(low.isLowS()); + assertSame(low, low.normalize()); + + // s' = n - s is the same signature in its other form + byte[] n = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); + byte[] s = low.s(); + byte[] high = new byte[32]; + int borrow = 0; + for (int i = 31; i >= 0; i--) { + int d = (n[i] & 0xff) - (s[i] & 0xff) - borrow; + high[i] = (byte) d; + borrow = d < 0 ? 1 : 0; + } + Secp256k1.EcdsaSignature highSig = new Secp256k1.EcdsaSignature(low.r(), high, low.recoveryId() ^ 1); + assertFalse(highSig.isLowS()); + assertFalse(Secp256k1.verify(hash, highSig, pub)); + assertArrayEquals(pub, Secp256k1.recoverPublicKey(hash, highSig, true)); + + Secp256k1.EcdsaSignature normalised = highSig.normalize(); + assertTrue(normalised.isLowS()); + assertArrayEquals(low.toCompact(), normalised.toCompact()); + assertEquals(low.recoveryId(), normalised.recoveryId()); + assertTrue(Secp256k1.verify(hash, normalised, pub)); + assertEquals(-1, new Secp256k1.EcdsaSignature(low.r(), high, -1).normalize().recoveryId()); + } + + @Test + void signatureEncoding() { + Secp256k1.EcdsaSignature sig = Secp256k1.sign(Hash.sha256(ascii("x")), key(9)); + Secp256k1.EcdsaSignature again = Secp256k1.EcdsaSignature.fromCompact(sig.toCompact(), sig.recoveryId()); + assertArrayEquals(sig.r(), again.r()); + assertArrayEquals(sig.s(), again.s()); + assertEquals(sig.recoveryId(), again.recoveryId()); + assertThrows(CryptoException.class, () -> new Secp256k1.EcdsaSignature(new byte[31], new byte[32], 0)); + assertThrows(CryptoException.class, () -> new Secp256k1.EcdsaSignature(new byte[32], new byte[32], 4)); + assertThrows(CryptoException.class, () -> Secp256k1.EcdsaSignature.fromCompact(new byte[63], 0)); + // accessors hand out copies + byte[] r = sig.r(); + r[0] ^= 0x55; + assertFalse(java.util.Arrays.equals(r, sig.r())); + } + + // ---------------------------------------------------------------- + // tweaks and point addition (BIP32) + + @Test + void tweaksAgreeOnBothSidesOfTheCurve() { + assertArrayEquals(key(2), Secp256k1.privateKeyTweakAdd(key(1), key(1))); + assertArrayEquals(Secp256k1.publicKey(key(2), true), + Secp256k1.publicKeyTweakAdd(Secp256k1.publicKey(key(1), true), key(1), true)); + assertArrayEquals(Secp256k1.publicKey(key(3), false), + Secp256k1.publicKeyAdd(Secp256k1.publicKey(key(1), true), Secp256k1.publicKey(key(2), false), false)); + + byte[] priv = hex("4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"); + byte[] tweak = Hash.sha256(ascii("child 0")); + byte[] childPriv = Secp256k1.privateKeyTweakAdd(priv, tweak); + assertArrayEquals(Secp256k1.publicKey(childPriv, true), + Secp256k1.publicKeyTweakAdd(Secp256k1.publicKey(priv, false), tweak, true)); + + // wrapping past n + assertArrayEquals(key(1), Secp256k1.privateKeyTweakAdd(hex(N_MINUS_1), key(2))); + assertThrows(CryptoException.class, () -> Secp256k1.privateKeyTweakAdd(hex(N_MINUS_1), key(1))); + assertThrows(CryptoException.class, () -> Secp256k1.publicKeyTweakAdd( + Secp256k1.publicKey(hex(N_MINUS_1), true), key(1), true)); + assertThrows(CryptoException.class, () -> Secp256k1.publicKeyAdd( + Secp256k1.publicKey(key(1), true), Secp256k1.publicKey(hex(N_MINUS_1), true), true)); + assertThrows(CryptoException.class, () -> Secp256k1.privateKeyTweakAdd(key(1), + hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"))); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/security/SecureStorageBindingTest.java b/maven/core-unittests/src/test/java/com/codename1/security/SecureStorageBindingTest.java new file mode 100644 index 00000000000..a91457e6d0a --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/security/SecureStorageBindingTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The strict biometric binding is opt-in: an app that does not ask keeps the +/// key rules it was installed with. +class SecureStorageBindingTest { + @Test + void strictBindingIsOffUntilAsked() { + SecureStorage storage = new SecureStorage() { + }; + assertFalse(storage.isStrictBiometricBinding()); + storage.setStrictBiometricBinding(true); + assertTrue(storage.isStrictBiometricBinding()); + storage.setStrictBiometricBinding(false); + assertFalse(storage.isStrictBiometricBinding()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/SensitiveClipboardTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/SensitiveClipboardTest.java new file mode 100644 index 00000000000..cbf2c70210b --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/SensitiveClipboardTest.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/// The sensitive-clipboard flags and the scheduled clear `ClipboardContent#setClearAfter(int)` +/// asks for. +class SensitiveClipboardTest extends UITestBase { + + private static String pasted() { + ClipboardContent now = Display.getInstance().getClipboardContent(); + return now == null ? null : now.getText(ClipboardContent.MIME_TEXT); + } + + private void waitPast(int millis) throws InterruptedException { + long until = System.currentTimeMillis() + millis; + while (System.currentTimeMillis() < until) { + flushSerialCalls(); + Thread.sleep(10); + } + flushSerialCalls(); + } + + @FormTest + void flagsAreKept() { + ClipboardContent content = new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "123456") + .setSensitive(true) + .setClearAfter(30000); + assertTrue(content.isSensitive()); + assertEquals(30000, content.getClearAfter()); + assertFalse(new ClipboardContent().isSensitive()); + assertEquals(0, new ClipboardContent().setClearAfter(-5).getClearAfter()); + } + + @FormTest + void clearedWhenTheTimeIsUp() throws InterruptedException { + Display.getInstance().copyToClipboard(new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "recovery-code").setSensitive(true) + .setClearAfter(50)); + assertEquals("recovery-code", pasted()); + waitPast(400); + assertNull(pasted(), "the sensitive copy was taken off the clipboard"); + } + + @FormTest + void aLaterCopyIsNeverCleared() throws InterruptedException { + Display.getInstance().copyToClipboard(new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "secret").setClearAfter(50)); + Display.getInstance().copyToClipboard("the user's own copy"); + waitPast(400); + assertEquals("the user's own copy", pasted()); + } + + @FormTest + void keptWithoutAClearTime() throws InterruptedException { + Display.getInstance().copyToClipboard(new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "plain").setSensitive(true)); + waitPast(200); + assertEquals("plain", pasted()); + } + + @FormTest + void anotherApplicationsCopyIsNeverCleared() throws InterruptedException { + Display.getInstance().copyToClipboard(new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "secret").setClearAfter(50)); + // Straight to the port, as a copy made in another application reaches the system + // clipboard: this application has copied nothing since, so the clear still fires. + implementation.copyToClipboard("another app's copy"); + waitPast(400); + assertEquals("another app's copy", pasted()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/util/BigIntegerModInverseTest.java b/maven/core-unittests/src/test/java/com/codename1/util/BigIntegerModInverseTest.java new file mode 100644 index 00000000000..b00010f35aa --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/util/BigIntegerModInverseTest.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.util; + +import org.junit.jupiter.api.Test; + +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/// Holds `modInverse` to `java.math.BigInteger` on the sizes elliptic-curve +/// code uses: 256-bit moduli, odd and even, including the P-256 group order +/// and field prime. +class BigIntegerModInverseTest { + + private static final String P256_ORDER = + "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"; + private static final String P256_PRIME = + "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff"; + + private static void check(java.math.BigInteger a, java.math.BigInteger m) { + java.math.BigInteger expected; + try { + expected = a.modInverse(m); + } catch (ArithmeticException e) { + final BigInteger ca = new BigInteger(a.toString(16), 16); + final BigInteger cm = new BigInteger(m.toString(16), 16); + assertThrows(ArithmeticException.class, () -> ca.modInverse(cm), "a=" + a.toString(16) + " m=" + m.toString(16)); + return; + } + BigInteger actual = new BigInteger(a.toString(16), 16).modInverse(new BigInteger(m.toString(16), 16)); + assertEquals(expected.toString(16), actual.toString(16), "a=" + a.toString(16) + " m=" + m.toString(16)); + } + + @Test + void namedCurveModuli() { + Random r = new Random(1234); + java.math.BigInteger[] mods = {new java.math.BigInteger(P256_ORDER, 16), new java.math.BigInteger(P256_PRIME, 16)}; + for (java.math.BigInteger m : mods) { + for (int i = 0; i < 500; i++) { + java.math.BigInteger a = new java.math.BigInteger(256, r).mod(m); + if (a.signum() == 0) { + continue; + } + check(a, m); + check(a.negate(), m); + } + } + } + + @Test + void randomModuli() { + Random r = new Random(98765); + for (int i = 0; i < 2000; i++) { + int bits = (i % 3 == 0) ? 255 + r.nextInt(3) : 256; + java.math.BigInteger m = new java.math.BigInteger(bits, r).setBit(bits - 1); + if ((i & 1) == 0) { + m = m.setBit(0); + } + java.math.BigInteger a = new java.math.BigInteger(256 + r.nextInt(64), r); + if (a.signum() == 0 || m.compareTo(java.math.BigInteger.ONE) <= 0) { + continue; + } + check(a, m); + } + } + + /// The inverse was wrong mostly because division was: `TDivision.divide` + /// normalized with a 64-bit leading-zero count applied to a 32-bit digit. + @Test + void divisionAndBitCount() { + Random r = new Random(4242); + for (int i = 0; i < 2000; i++) { + java.math.BigInteger a = new java.math.BigInteger(64 + r.nextInt(512), r); + java.math.BigInteger b = new java.math.BigInteger(1 + r.nextInt(300), r).add(java.math.BigInteger.ONE); + if ((i & 1) == 0) { + a = a.negate(); + } + BigInteger ca = new BigInteger(a.toString(16), 16); + BigInteger cb = new BigInteger(b.toString(16), 16); + BigInteger[] qr = ca.divideAndRemainder(cb); + java.math.BigInteger[] eqr = a.divideAndRemainder(b); + String ctx = "a=" + a.toString(16) + " b=" + b.toString(16); + assertEquals(eqr[0].toString(16), qr[0].toString(16), ctx); + assertEquals(eqr[1].toString(16), qr[1].toString(16), ctx); + assertEquals(a.mod(b).toString(16), ca.mod(cb).toString(16), ctx); + assertEquals(a.bitCount(), ca.bitCount(), ctx); + assertEquals(a.gcd(b).toString(16), ca.gcd(cb).toString(16), ctx); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/util/UtilBigNumberTest.java b/maven/core-unittests/src/test/java/com/codename1/util/UtilBigNumberTest.java index ecb02aa1b35..18d54d8fd95 100644 --- a/maven/core-unittests/src/test/java/com/codename1/util/UtilBigNumberTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/util/UtilBigNumberTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.util; import com.codename1.junit.FormTest; @@ -107,4 +129,15 @@ void bigDecimalInteropWithBigIntegerScale() { assertEquals(decimal.intValue(), decimal.floor().intValue()); assertEquals(decimal.longValue(), decimal.floor().longValue()); } + + @FormTest + void bigDecimalRoundAndFloor() { + assertEquals(new BigInteger("12"), new BigDecimal(new BigInteger("124"), 1).round()); + assertEquals(new BigInteger("13"), new BigDecimal(new BigInteger("125"), 1).round()); + assertEquals(new BigInteger("13"), new BigDecimal(new BigInteger("126"), 1).round()); + assertEquals(new BigInteger("-12"), new BigDecimal(new BigInteger("-124"), 1).round()); + assertEquals(new BigInteger("-13"), new BigDecimal(new BigInteger("-126"), 1).round()); + assertEquals(new BigInteger("-3"), new BigDecimal(new BigInteger("-3"), 0).floor()); + assertEquals(new BigInteger("-4"), new BigDecimal(new BigInteger("-32"), 1).floor()); + } } diff --git a/scripts/bastion/.gitignore b/scripts/bastion/.gitignore new file mode 100644 index 00000000000..2f7896d1d13 --- /dev/null +++ b/scripts/bastion/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/scripts/bastion/LICENSE b/scripts/bastion/LICENSE new file mode 100644 index 00000000000..6a09ed8e735 --- /dev/null +++ b/scripts/bastion/LICENSE @@ -0,0 +1,22 @@ +MIT No Attribution + +Copyright (c) 2026, Codename One and/or its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +This applies to the Savor sample and to everything Codename One Initializr +writes into a generated project: the application templates under +maven/cn1app-archetype/src/main/resources/archetype-resources, the agent skill +under scripts/initializr/common/src/main/resources/skill, and the Initializr +template archives built from them. diff --git a/scripts/bastion/README.adoc b/scripts/bastion/README.adoc new file mode 100644 index 00000000000..1d10d966705 --- /dev/null +++ b/scripts/bastion/README.adoc @@ -0,0 +1,118 @@ += Bastion + +A virtual bank and its server, in one Maven project and one language. Customers +hold dollars, euros, bitcoin and ether, move money between their accounts and to +other customers, issue and freeze cards, trade on a small exchange and withdraw +coins to addresses they have allowed; an administrator verifies the ledger and the +audit chain. Every security feature a financial app needs is wired in, and the +app's Security Center lists each one with its real state on the device it runs on. + +It is a sample of how a Codename One application and a Codename One Backend fit +together when security is the point, with a ledger general enough to be the base +of a crypto exchange. Nothing in it is real money. + +== Start here + +Two chapters of the developer guide cover this project: + +* https://www.codenameone.com/developer-guide/#bastion-users-guide[The user's guide] + (link:../../docs/developer-guide/Bastion-Users-Guide.asciidoc[source]) shows the + app as a customer and an administrator use it, with the demo accounts. +* https://www.codenameone.com/developer-guide/#bastion-implementors-guide[The implementor's guide] + (link:../../docs/developer-guide/Bastion-Implementors-Guide.asciidoc[source]) + explains how it is built, every security feature and where it lives, the threat + model, what only a cloud build turns on, and what a real bank still has to add. + +== The modules + +[cols="1,4"] +|=== +| `shared` | Transfer objects, the `@RestClient` contracts and the canonical form of a signed request. +| `backend` | The server: ledger, sign-in with a second factor and passkeys, money movement with request signing and limits, the exchange, the audit chain. +| `common` | The app: every screen, the network guard, the device key, the app lock, the screen protections and the Security Center. +|=== + +== Running it + +Start the server on the development profile. It needs nothing installed: the +database is in memory, and a market maker keeps the exchange's books moving. + +[source,bash] +---- +CN1_PROFILE=dev backend/server.sh run +---- + +Then run the application in the simulator, from another terminal: + +[source,bash] +---- +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +---- + +The development profile creates these accounts, all with the password +`bastion-demo-password`: + +[cols="2,3"] +|=== +| `customer@bastion.example`, `customer2@bastion.example` | customers with money in their checking accounts +| `admin@bastion.example` | the administrator +|=== + +`backend/server.sh run --web` also builds the app for the browser and serves it at +`/`. + +== What is real and what is simulated + +Real: the passwords, the authenticator codes, passkeys, the device keys and the +signatures over every payment, the limits and cooling-off periods, the ledger's +double entry and the audit chain. + +Simulated: deposits (a counter), card spending, the coins (entries in the ledger, +never on a chain), the market (a random walk on the development profile), and the +device integrity check (`SimulatedIntegrityVerifier` believes the device). The +hardening build hints take effect only in a build from the Codename One build +server; the Security Center says so. + +== Tests + +[source,bash] +---- +mvn -pl shared,backend -Dcodename1.platform=backend test # <1> +mvn -pl shared,backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true # <2> +CN1_TEST_DATABASE_URL=... ../ci/sample-server-tests-on-database.sh . # <3> +mvn -Dcodename1.platform=javase -Djava.awt.headless=true package # <4> +./run-e2e.sh # <5> +./run-web-e2e.sh # <6> +---- +<1> The server's tests, on the JVM. +<2> The same tests again, translated to a native binary, which is how the server + ships. +<3> The server's tests on PostgreSQL or MySQL. MySQL has to run at READ + COMMITTED; the server refuses to start otherwise (`ledger/IsolationCheck.java`). +<4> The app's own tests, headless: the request signer, the network guard, the + declared build hints, the bundle and the QR encoder. +<5> Starts the server on the `test` profile and drives it over HTTP the way the + app does (`e2e/LiveE2e.java`): it registers two customers, enrols an + authenticator and signs in with it, registers device keys, makes a signed + payment and has the unsigned and replayed copies refused, fills an order + between the two, and has a withdrawal refused until its address is allowed + and cooled off. Pass `--native` to run it against the compiled server. +<6> Builds the browser version of the application, starts the server hosting + it, and drives it in a headless browser: it opens an account, turns on the + authenticator app and creates a passkey, signs in again with the password + and a code, moves money between two accounts, then signs in with the + passkey alone and registers the browser as a signing device. Needs Node with Playwright and its Chromium (`npm ci` and + `npx playwright install chromium` in `scripts/`). Screenshots and logs go to + `target/web-e2e`. With `--guide` it runs the server on the development + profile and then takes every screenshot in the user's guide, into + `docs/developer-guide/img`. + +`.github/workflows/bastion.yml` runs all of them, and builds the application for +Android and iOS through `scripts/build-android-app.sh` and +`scripts/build-ios-app.sh` with `CN1_APP_DIR=scripts/bastion`. + +== Licence + +Bastion is released under MIT No Attribution (see `LICENSE`), not under the +GPLv2 with Classpath Exception that covers Codename One itself. Take any of it +into your own application, under any licence, with no notice to keep. diff --git a/scripts/bastion/backend/application-dev.properties b/scripts/bastion/backend/application-dev.properties new file mode 100644 index 00000000000..44aba24fbc7 --- /dev/null +++ b/scripts/bastion/backend/application-dev.properties @@ -0,0 +1 @@ +cn1.datasource.url=:memory: diff --git a/scripts/bastion/backend/application-test.properties b/scripts/bastion/backend/application-test.properties new file mode 100644 index 00000000000..1ccd543e4da --- /dev/null +++ b/scripts/bastion/backend/application-test.properties @@ -0,0 +1,13 @@ +cn1.datasource.url=:memory: + +bastion.signin.per-minute=1000 + +bastion.register.per-hour=1000 +bastion.webauthn.origins=http://bastion.test:8080 + +bastion.transfer.per-minute=1000 + +bastion.market-maker.enabled=false +bastion.markets=BTC-USD:BTC:USD:1000:6000000,ETH-USD:ETH:USD:100000:300000,T1-USD:BTC:USD:1000:6000000,T2-USD:BTC:USD:1000:6000000,T3-USD:BTC:USD:1000:6000000,T4-USD:BTC:USD:1000:6000000,T5-USD:BTC:USD:1000:6000000,T6-USD:BTC:USD:1000:6000000,T7-USD:BTC:USD:1000:6000000,T8-USD:BTC:USD:1000:6000000,T9-USD:BTC:USD:1000:97500,T10-USD:BTC:USD:1000:6000000,T11-USD:BTC:USD:1000:6000000,STORM-USD:BTC:USD:1000:6000000 + +bastion.demo.history-days=10 diff --git a/scripts/bastion/backend/application.properties b/scripts/bastion/backend/application.properties new file mode 100644 index 00000000000..07b078d69ef --- /dev/null +++ b/scripts/bastion/backend/application.properties @@ -0,0 +1,39 @@ +# The settings this server reads at start-up, and the layer with the lowest +# precedence. A system property of the same name wins over anything here, then +# the environment variable the key maps to (cn1.datasource.url is read from +# CN1_DATASOURCE_URL, and also from DATABASE_URL), then +# application-.properties, then this file. Choose a profile with +# CN1_PROFILE; `dev` needs nothing else: +# +# CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend +# +# A real deployment sets, in its environment and never in this file: +# +# cn1.datasource.url the database +# cn1.security.authorizationserver.issuer the public https address +# cn1.security.authorizationserver.jwk.keys the key tokens are signed with +# cn1.security.mfa.encryptionKey the key authenticator secrets are encrypted under +# bastion.webauthn.rp-id, bastion.webauthn.origins the site passkeys are bound to +# bastion.admin.email, bastion.admin.password the first admin +# +# Outside the dev and test profiles the server refuses to start while any of +# the keys, the passkey site or its origins is left at what ships here +# (ProductionSettings). +# +# Behind a load balancer every request comes from the balancer's address, and +# the sign-in and registration limits would be one bucket for the whole +# world. Name the balancer, and only then is X-Forwarded-For believed -- from +# that peer and no other, so a client cannot pick its own address: +# +# cn1.server.forwardHeaders=true +# cn1.server.trustedProxies=10.0.0.0/8 +# +# Neither is set by default: no peer is a proxy, and the header is ignored. +cn1.datasource.url=${DATABASE_URL} + +cn1.server.port=8080 + +# Creates the tables the accounts, the registered app, the tokens, the +# authenticator apps and the passkeys are kept in. Read by the build, so it has +# to be here and not in a profile's file. +cn1.security.schema.enabled=true diff --git a/scripts/bastion/backend/pom.xml b/scripts/bastion/backend/pom.xml new file mode 100644 index 00000000000..0efb3c46397 --- /dev/null +++ b/scripts/bastion/backend/pom.xml @@ -0,0 +1,160 @@ + + + 4.0.0 + + com.codenameone.examples.bastion + bastion + 1.0-SNAPSHOT + + com.codenameone.examples.bastion + bastion-backend + 1.0-SNAPSHOT + jar + + bastion-backend + + + + + com.codenameone + codenameone-backend + ${cn1.version} + + + + ${project.groupId} + bastion-shared + ${project.version} + + + + org.xerial + sqlite-jdbc + 3.46.1.0 + runtime + + + + com.codenameone + codenameone-backend-test + ${cn1.version} + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + cn1-generate-annotation-stubs + generate-sources + + + generate-annotation-stubs + + + + cn1-process-annotations + process-classes + + + process-annotations + + + + cn1-process-test-annotations + process-test-classes + + + process-test-annotations + + + + cn1-compiled-tests + test + + + backend-test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + false + + + + + diff --git a/scripts/bastion/backend/server.sh b/scripts/bastion/backend/server.sh new file mode 100755 index 00000000000..c99a8ce223b --- /dev/null +++ b/scripts/bastion/backend/server.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Builds and runs the Bastion server. +# +# server.sh build [--jvm|--native] [--web] +# server.sh run [--jvm|--native] [--web] [--prebuilt] [--port N] +# server.sh web +# +# --jvm (the default) runs the server on this JDK, which is how you work on it. +# --native compiles it to a native binary, which is how it ships. +# +# The profile comes from CN1_PROFILE. `test` and `dev` need nothing else: an +# in-memory database, four demo accounts, a geocoder that knows ten places +# (`test`) and text messages that are printed, not sent. Anything else is a +# deployment and reads its settings from the environment -- see +# application.properties. +# +# `web` builds the app for the browser and stages it in target/webapp, which +# takes about a minute; `--web` does that as part of a build or a run. A server +# that finds the app there serves it at /, so http://localhost:8080/ is Bastion +# with nothing installed. It stays staged until the next `mvn clean`: build it +# once, and again when the app changed. +# +# The server prints "listening on http port N" when it accepts connections. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP="$(cd "$HERE/.." && pwd)" +# The project's own Maven wrapper where it has one. +if [ -z "${MVN:-}" ]; then + if [ -x "$APP/mvnw" ]; then MVN="$APP/mvnw"; else MVN="mvn"; fi +fi + +log() { echo "[bastion-server] $*" >&2; } + +build_jvm() { + log "building the server for the JVM" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl shared,backend \ + -Dmaven.test.skip=true package dependency:copy-dependencies \ + -DincludeScope=runtime -DoutputDirectory="$APP/backend/target/server-lib" >&2 +} + +build_native() { + log "translating the server to a native binary" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl shared,backend \ + -Dmaven.test.skip=true package cn1:backend-package \ + -Dcn1.backend.output="$APP/backend/target/bastion-server" >&2 +} + +build_web() { + log "building the app for the browser" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl backend \ + cn1:backend-webapp >&2 +} + +# The server looks for the app in `webapp` beside its settings; here it is build +# output, so it is under target and the server is told. An address set by the +# caller is left alone. +serve_web() { + if [ -z "${CN1_WEBAPP_ROOT:-}" ] && [ -f "$APP/backend/target/webapp/index.html" ]; then + export CN1_WEBAPP_ROOT="target/webapp" + log "serving the web app in backend/target/webapp" + fi +} + +command="${1:-run}" +shift || true +mode="jvm" +port="${PORT:-8080}" +prebuilt=0 +web=0 +while [ $# -gt 0 ]; do + case "$1" in + --jvm) mode="jvm" ;; + --native) mode="native" ;; + --prebuilt) prebuilt=1 ;; + --web) web=1 ;; + --port) port="$2"; shift ;; + *) log "unknown argument $1"; exit 2 ;; + esac + shift +done + +case "$command" in + build) + if [ "$mode" = "native" ]; then build_native; else build_jvm; fi + [ "$web" = 0 ] || build_web + ;; + web) + build_web + ;; + run) + # application.properties is read from the working directory. + cd "$APP/backend" + if [ "$mode" = "native" ]; then + [ "$prebuilt" = 1 ] || build_native + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + PORT="$port" exec "$APP/backend/target/bastion-server" + fi + [ "$prebuilt" = 1 ] || build_jvm + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + main="$(tr -d '\r\n' < "$APP/backend/target/classes/META-INF/cn1-backend-main")" + log "running $main on port $port" + PORT="$port" exec java -cp "$APP/backend/target/classes:$APP/backend/target/server-lib/*" "$main" + ;; + *) + log "unknown command $command (build, run, web)" + exit 2 + ;; +esac diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Caller.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Caller.java new file mode 100644 index 00000000000..c559568e362 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Caller.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.SecurityContextHolder; +import com.codename1.backend.security.SecurityExchange; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationToken; + +import java.util.Iterator; + +/// Who is asking. Every endpoint reads the caller from here and never from a +/// parameter, so there is no id in a request to change to reach someone +/// else's money. +public final class Caller { + private Caller() { + } + + public static String name() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || !who.isAuthenticated() || who.getName() == null) { + throw Refused.with(401, "sign-in-first", "Sign in first"); + } + return who.getName(); + } + + public static boolean hasRole(String role) { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || who.getAuthorities() == null) { + return false; + } + String wanted = "ROLE_" + role; + Iterator it = who.getAuthorities().iterator(); + while (it.hasNext()) { + if (wanted.equals(it.next().getAuthority())) { + return true; + } + } + return false; + } + + /// The client's address as the server saw it, for the audit log; empty + /// outside a request. + /// When the caller's token says they last proved who they are -- the + /// `auth_time` claim, epoch seconds, which [SecurityConfig] copies from + /// the sign-in into every access token, refreshed ones included -- or -1 + /// when the caller has no such token. + public static long authTime() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (!(who instanceof JwtAuthenticationToken)) { + return -1L; + } + Long at = ((JwtAuthenticationToken) who).getToken().getClaimAsLong("auth_time"); + return at == null ? -1L : at.longValue(); + } + + /// How the caller's sign-in was made: the token's `amr` claim (RFC 8176 + /// names -- `pwd`, `otp`, `hwk`, `mfa`), empty when it carries none. + public static java.util.List authMethods() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (!(who instanceof JwtAuthenticationToken)) { + return java.util.Collections.emptyList(); + } + java.util.List amr = ((JwtAuthenticationToken) who).getToken() + .getClaimAsStringList("amr"); + return amr == null ? java.util.Collections.emptyList() : amr; + } + + public static String address() { + SecurityExchange exchange = SecurityExchange.current(); + if (exchange == null) { + return ""; + } + String address = exchange.getRequest().getRemoteAddress(); + return address == null ? "" : address; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Ids.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Ids.java new file mode 100644 index 00000000000..a449a165bfb --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Ids.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.Crypto; + +import java.io.IOException; + +/// Identifiers and the hashes the ledger and the audit chain are built on. +public final class Ids { + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private Ids() { + } + + /// 128 random bits as hex. Every id in this server is one: nothing is a + /// counter, so no id says how many customers or transfers there are, and + /// none can be guessed from another. + public static String next() throws IOException { + return hex(Crypto.randomBytes(16)); + } + + public static String hex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + for (int iter = 0; iter < bytes.length; iter++) { + out[iter * 2] = HEX[(bytes[iter] >> 4) & 0xf]; + out[iter * 2 + 1] = HEX[bytes[iter] & 0xf]; + } + return new String(out); + } + + public static String sha256(String text) throws IOException { + return hex(Crypto.sha256(text.getBytes("UTF-8"))); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ProductionSettings.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ProductionSettings.java new file mode 100644 index 00000000000..d73df63d615 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ProductionSettings.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Value; + +/// Refuses to start a server outside a development profile whose secrets or +/// passkey site are still what this sample ships with. +/// +/// The token-signing key, the key authenticator secrets are encrypted under +/// and the passkey site all have a value that makes `dev` work out of the +/// box: a key made at start-up, none, `localhost`. A deployment that forgot +/// one would otherwise run -- with tokens nothing else can verify, secrets +/// kept in the clear, or passkeys bound to a site that is not it -- and look +/// healthy. The same rule as +/// [com.codenameone.examples.bastion.money.SimulatedIntegrityVerifier]: fail +/// the start, naming what to set. +@Component +public class ProductionSettings { + static final String DEFAULT_RP_ID = "localhost"; + static final String DEFAULT_ORIGINS = "http://localhost:8080"; + + private final boolean development; + private final String jwkKeys; + private final String mfaKey; + private final String rpId; + private final String origins; + + public ProductionSettings(Config config, + @Value("${cn1.security.authorizationserver.jwk.keys:}") String jwkKeys, + @Value("${cn1.security.mfa.encryptionKey:}") String mfaKey, + @Value("${bastion.webauthn.rp-id:localhost}") String rpId, + @Value("${bastion.webauthn.origins:http://localhost:8080}") String origins) { + this.development = config.isDevelopmentProfile(); + this.jwkKeys = jwkKeys; + this.mfaKey = mfaKey; + this.rpId = rpId; + this.origins = origins; + } + + @PostConstruct + public void check() { + check(development, jwkKeys, mfaKey, rpId, origins); + } + + /// Throws naming the first setting left at its shipped value, unless + /// `development`. + public static void check(boolean development, String jwkKeys, String mfaKey, String rpId, + String origins) { + if (development) { + return; + } + if (blank(jwkKeys)) { + refuse("cn1.security.authorizationserver.jwk.keys"); + } + if (blank(mfaKey)) { + refuse("cn1.security.mfa.encryptionKey"); + } + if (blank(rpId) || DEFAULT_RP_ID.equals(rpId.trim())) { + refuse("bastion.webauthn.rp-id"); + } + if (blank(origins) || DEFAULT_ORIGINS.equals(origins.trim())) { + refuse("bastion.webauthn.origins"); + } + } + + private static boolean blank(String s) { + return s == null || s.trim().length() == 0; + } + + private static void refuse(String key) { + throw new IllegalStateException("Set " + key + " for this deployment: outside the dev " + + "and test profiles Bastion does not start on the value it ships with."); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Refused.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Refused.java new file mode 100644 index 00000000000..7e0d4f2670f --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Refused.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.ResponseStatusException; +import com.codenameone.examples.bastion.api.Refusal; + +/// A refusal the app can translate: the status, a stable code and the English +/// sentence, worded by [Refusal]. +public final class Refused { + private Refused() { + } + + /// `english` names `values` as `{0}`, `{1}`...; the app's bundle has the + /// same sentence under `error.`. + public static ResponseStatusException with(int status, String code, String english, + Object... values) { + return new ResponseStatusException(status, Refusal.format(code, english, values)); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Roles.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Roles.java new file mode 100644 index 00000000000..77491643f9a --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Roles.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +/// The roles an account can hold. Every account opened through the app is a +/// customer; ADMIN and AUDITOR are given only by configuration +/// ([account.DemoAccounts]) and never by any request, so no bug in an endpoint +/// can hand them out. +/// +/// The three are disjoint, which is the segregation of duties a bank's +/// examiners ask for: a customer moves their own money, an ADMIN operates the +/// books (adjusts, reverses, closes periods -- above a threshold only with a +/// second admin's approval) and can never rewrite the audit chain, and an +/// AUDITOR reads everything and can change nothing. Every read a member of +/// staff makes of the books or the audit trail is itself audited. +public final class Roles { + public static final String CUSTOMER = "CUSTOMER"; + public static final String ADMIN = "ADMIN"; + public static final String AUDITOR = "AUDITOR"; + + private Roles() { + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/SecurityConfig.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/SecurityConfig.java new file mode 100644 index 00000000000..263e67984e5 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/SecurityConfig.java @@ -0,0 +1,604 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Order; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.security.AntPathRequestMatcher; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.DisabledException; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.HeadersConfigurer; +import com.codename1.backend.security.HttpSecurity; +import com.codename1.backend.security.HttpStatusReturningLogoutSuccessHandler; +import com.codename1.backend.security.LogoutFilter; +import com.codename1.backend.security.RequestMatcher; +import com.codename1.backend.security.SecondFactorAuthenticationFilter; +import com.codename1.backend.security.SecurityContextHolder; +import com.codename1.backend.security.SecurityExchange; +import com.codename1.backend.security.SecurityFilter; +import com.codename1.backend.security.SecurityFilterChain; +import com.codename1.backend.security.SessionCreationPolicy; +import com.codename1.backend.security.UsernamePasswordAuthenticationFilter; +import com.codename1.backend.security.WebAuthnRegistrationFilter; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.crypto.JwkSource; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.crypto.PasswordEncoderFactories; +import com.codename1.backend.security.mfa.JdbcRecoveryCodeRepository; +import com.codename1.backend.security.mfa.JdbcTotpRepository; +import com.codename1.backend.security.mfa.RecoveryCodeService; +import com.codename1.backend.security.mfa.TotpService; +import com.codename1.backend.security.oauth2.core.AuthorizationGrantType; +import com.codename1.backend.security.oauth2.core.ClientAuthenticationMethod; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtDecoder; +import com.codename1.backend.security.oauth2.jwt.JwtAudienceValidator; +import com.codename1.backend.security.oauth2.jwt.JwtClaimValidator; +import com.codename1.backend.security.oauth2.jwt.JwtIssuerValidator; +import com.codename1.backend.security.oauth2.jwt.JwtValidators; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerKeys; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerSettings; +import com.codename1.backend.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.JdbcRegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2TokenContext; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClient; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.authorization.TokenSettings; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationConverter; +import com.codename1.backend.security.ratelimit.InMemoryRateLimiter; +import com.codename1.backend.security.ratelimit.RateLimitKeys; +import com.codename1.backend.security.webauthn.JdbcPublicKeyCredentialUserEntityRepository; +import com.codename1.backend.security.webauthn.JdbcUserCredentialRepository; +import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntityRepository; +import com.codename1.backend.security.webauthn.UserCredentialRepository; +import com.codenameone.examples.bastion.account.SignInGuard; +import com.codenameone.examples.bastion.audit.Audit; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/// Who may call what, and how they prove who they are. +/// +/// This server is two things at once, with a filter chain for each, as Savor's +/// is: +/// +/// - **An authorization server.** The app signs a customer in here -- a +/// password, then a code from their authenticator app if they have one, or +/// a passkey instead of both -- and then runs the authorization-code flow +/// with PKCE for a five-minute access token and a refresh token. Nothing but +/// this server ever sees the password. +/// - **An API.** Everything under `/api` takes that access token as a bearer +/// token, keeps no session, and decides from the account's roles, read +/// fresh on every request, what the caller may do. +/// +/// What a bank adds over Savor, each a few lines below: a second factor +/// (`mfa`), passkeys (`webAuthn`), a lockout per account on top of the rate +/// limit per address ([SignInGuard]), stricter headers, shorter tokens that +/// rotate, and an audit record of every sign-in, refused or not. +@Configuration +public class SecurityConfig { + /// The id the app signs in as. A public client: it holds no secret, since + /// anything shipped inside an app can be read out of it, and proves itself + /// with PKCE instead. + public static final String CLIENT_ID = "bastion-app"; + /// Where the authorization code is sent back to. The app makes the + /// requests of the flow itself and reads the code out of the redirect, so + /// this is never opened; it is the loopback address because that is the + /// one address nobody else can register a listener on. + public static final String LOOPBACK_REDIRECT = "http://127.0.0.1/callback"; + /// Where the code is sent back to when the app runs in a browser: a path on + /// this server, which answers with the code it was given. See + /// [#withWebRedirect]. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + /// Whom the access tokens are for. The API accepts no token issued for + /// anything else. + public static final String AUDIENCE = "urn:bastion:api"; + public static final String ISSUER_KEY = "cn1.security.authorizationserver.issuer"; + /// Five minutes. An access token cannot be withdrawn once issued -- the + /// API checks its signature, not a list -- so its lifetime is how long a + /// stolen one is good for. The roles behind it are read on every request, + /// so a suspension still takes effect at once. + public static final long ACCESS_TOKEN_SECONDS = 300; + /// Twelve hours of being signed in without the password, a banking app's + /// usual day. Each refresh hands back a new refresh token and retires the + /// old one, so a copied refresh token is good until its owner next uses + /// theirs. + public static final long REFRESH_TOKEN_SECONDS = 12 * 3600; + + @Bean + public PasswordEncoder passwordEncoder() { + // A hash that names its algorithm, `{bcrypt}...`, so a stronger one + // can be phased in later without anyone's password being reset. + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + /// The accounts, in the tables `cn1.security.schema.enabled` creates. + @Bean + public JdbcUserDetailsManager users(DataSource db) { + return new JdbcUserDetailsManager(db); + } + + @Bean + public RegisteredClientRepository clients(DataSource db, + @Value("${" + ISSUER_KEY + ":}") final String issuer) throws IOException { + final JdbcRegisteredClientRepository clients = new JdbcRegisteredClientRepository(db); + // Written once. A database from before a change to these settings + // keeps its old row; drop it (or the database, on a development + // profile) to pick the change up. + if (clients.findByClientId(CLIENT_ID) == null) { + clients.save(RegisteredClient.withId("bastion").clientId(CLIENT_ID) + .clientName("Bastion") + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri(LOOPBACK_REDIRECT) + .scope("openid").scope("profile") + .tokenSettings(TokenSettings.builder() + .accessTokenTimeToLive(ACCESS_TOKEN_SECONDS) + .refreshTokenTimeToLive(REFRESH_TOKEN_SECONDS) + .reuseRefreshTokens(false) + .build()) + .build()); + } + // The app in a browser is the same client with one more address: this + // server's own. It is worked out and not stored, because on a + // development profile the server has no one address to store -- and a + // row written by an earlier version would not have it either. + return new RegisteredClientRepository() { + @Override + public void save(RegisteredClient client) { + clients.save(client); + } + + @Override + public RegisteredClient findByClientId(String clientId) { + return withWebRedirect(clients.findByClientId(clientId), issuer); + } + + @Override + public RegisteredClient findById(String id) { + return withWebRedirect(clients.findById(id), issuer); + } + }; + } + + /// The app's registration with [#WEB_REDIRECT_PATH] on this server added + /// to the addresses a code may be sent back to. + /// + /// A browser will not show a page the answer to a request that was + /// redirected to another origin, so the app running in one cannot read its + /// code off a redirect to the loopback address the way the installed app + /// does. It asks for the code to be sent to this server instead, which + /// hands it straight back ([SignInCodePage]). + /// + /// "This server" is the configured issuer where there is one, and is then + /// the only web address accepted. With none -- a development profile -- it + /// is the address the request arrived at, the same rule + /// [#issuedAtThisAddress] holds a token to: a code is only ever sent back + /// to the server the browser was already talking to. + static RegisteredClient withWebRedirect(RegisteredClient client, String issuer) { + if (client == null || !CLIENT_ID.equals(client.getClientId())) { + return client; + } + String here = here(issuer); + if (here == null) { + return client; + } + RegisteredClient.Builder copy = RegisteredClient.withId(client.getId()) + .clientId(client.getClientId()) + .clientName(client.getClientName()) + .clientSettings(client.getClientSettings()) + .tokenSettings(client.getTokenSettings()) + .redirectUri(here + WEB_REDIRECT_PATH); + for (ClientAuthenticationMethod method : client.getClientAuthenticationMethods()) { + copy.clientAuthenticationMethod(method); + } + for (AuthorizationGrantType grant : client.getAuthorizationGrantTypes()) { + copy.authorizationGrantType(grant); + } + for (String uri : client.getRedirectUris()) { + copy.redirectUri(uri); + } + for (String scope : client.getScopes()) { + copy.scope(scope); + } + for (String resource : client.getResources()) { + copy.resource(resource); + } + return copy.build(); + } + + + /// Kept in the database, so a refresh token outlives a restart and is good + /// on every instance of the server. + @Bean + public OAuth2AuthorizationService authorizations(DataSource db) { + return new JdbcOAuth2AuthorizationService(db); + } + + /// The keys the tokens are signed with: the files named by + /// `cn1.security.authorizationserver.jwk.keys`. On a development profile + /// with none configured, a key made at start-up. + @Bean + public JwkSource signingKeys(Config config) throws IOException { + return AuthorizationServerKeys.load(config); + } + + /// Authenticator-app secrets, encrypted under + /// `cn1.security.mfa.encryptionKey` before they reach the database, so a + /// copy of the database alone does not give anyone the codes. A server + /// outside a development profile refuses to start without the key. + @Bean + public TotpService totp(DataSource db, Config config) { + return new TotpService(JdbcTotpRepository.fromConfig(db, config), "Bastion"); + } + + /// Recovery codes, kept as salted hashes: shown once, never again. + @Bean + public RecoveryCodeService recoveryCodes(DataSource db) { + return new RecoveryCodeService(new JdbcRecoveryCodeRepository(db)); + } + + /// Passkeys: the public keys, and the random handle each account is known + /// by to the authenticator (never the e-mail address). + @Bean + public UserCredentialRepository passkeys(DataSource db) { + return new JdbcUserCredentialRepository(db); + } + + @Bean + public PublicKeyCredentialUserEntityRepository passkeyHolders(DataSource db) { + return new JdbcPublicKeyCredentialUserEntityRepository(db); + } + + /// The headers both chains send. The defaults already include + /// `nosniff`, `X-Frame-Options: DENY` and `Cache-Control: no-store`; they + /// are spelled out here so that a reader of this file sees them, and so a + /// change to the defaults does not change a bank. HSTS is sent only on a + /// response over TLS this server terminated: two years, subdomains too. + static void hardened(HeadersConfigurer headers) { + headers.contentTypeOptions(nosniff -> { }) + .frameOptions(frame -> frame.deny()) + .cacheControl(cache -> { }) + .httpStrictTransportSecurity(hsts -> hsts + .maxAgeInSeconds(2 * 365 * 24 * 3600L).includeSubDomains(true)); + } + + /// Signing in. A session exists here and only here, for the few requests + /// between the password being accepted and the code being handed over; + /// the app ends it with `POST /logout` once it has its tokens. + @Bean + @Order(1) + public SecurityFilterChain signIn(HttpSecurity http, SignInGuard guard, Audit audit, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + @Value("${bastion.signin.per-minute:10}") int signInsPerMinute, + @Value("${bastion.webauthn.rp-id:localhost}") String rpId, + @Value("${bastion.webauthn.origins:http://localhost:8080}") String origins) + throws IOException { + http.securityMatcher("/oauth2/**", "/.well-known/**", "/userinfo", "/login", "/login/**", + "/logout", "/webauthn/**") + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .headers(SecurityConfig::hardened) + // No CSRF token, for the reason Savor gives: the app posts the + // forms itself and has no page to read a token from. The session + // cookie is SameSite=Lax and [#sameOrigin] refuses a change sent by + // another site's page, which together do the token's work. The + // passkey ceremonies answer a 32-byte challenge kept in the + // caller's session, which no other site can read. + .csrf(csrf -> csrf.disable()) + .addFilterBefore(sameOrigin(issuer), LogoutFilter.class) + .logout(logout -> logout + .logoutRequestMatcher(AntPathRequestMatcher.antMatcher("POST", "/logout")) + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(204))) + // The answers are statuses, not redirects to pages: the app reads + // how the request ended. A password accepted for an account with + // an authenticator app is not a sign-in yet -- `mfa` answers it + // with a redirect to /login/mfa, and these handlers run only when + // the code is in. + .formLogin(form -> form + .successHandler(guard.signedIn("password")) + .failureHandler(guard.refused("password"))) + .addFilterBefore(guard.lockout(), UsernamePasswordAuthenticationFilter.class) + .mfa(mfa -> mfa.successHandler(guard.signedIn("password+totp"))) + // The second-factor filter answers a wrong code with a redirect + // back to its page and offers no failure handler, so a wrong code + // is recorded from what came back. + .addFilterBefore(audited(AntPathRequestMatcher.antMatcher("POST", "/login/mfa"), + 302, "SIGNIN_FAILED", "totp", audit), + SecondFactorAuthenticationFilter.class) + // An account with a passkey finishes a password only with a + // recovery code ([SignInGuard#passkeyFirst]). + .addFilterBefore(guard.passkeyFirst(), SecondFactorAuthenticationFilter.class) + // Passkeys belong to the domain in rpId, and a ceremony is + // accepted only from the listed origins. The installed Android app + // reports `android:apk-key-hash:` as its origin; a release + // adds that beside the web origin. + .webAuthn(passkeys -> passkeys + .rpId(rpId) + .rpName("Bastion") + // Every passkey sign-in verifies the person -- a + // fingerprint, a face, the device PIN -- so a passkey is + // two factors in one and the strongest way in + // (SignInGuard holds an account to it). + .userVerification("required") + .allowedOrigins(split(origins)) + .successHandler(guard.signedIn("passkey")) + .failureHandler(guard.refused("passkey"))) + .addFilterBefore(audited(AntPathRequestMatcher.antMatcher("POST", "/webauthn/register"), + 200, "PASSKEY_ADDED", "", audit), WebAuthnRegistrationFilter.class) + .addFilterBefore(audited(AntPathRequestMatcher.antMatcher("DELETE", + "/webauthn/register/**"), 204, "PASSKEY_REMOVED", "", audit), + WebAuthnRegistrationFilter.class) + // Guessing is the attack on this chain: passwords, codes. A limit + // per route, from one address, unless `bastion.signin.per-minute` + // says otherwise -- the tests are many people signing in from one + // address, and their profile raises it. One limiter per rule: a + // shared one would share its count. The second factor also counts + // wrong codes per account on its own (cn1.security.mfa.attempts). + .rateLimit("/login", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(signInsPerMinute, 60)) + .rateLimit("/login/mfa", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(signInsPerMinute, 60)) + .rateLimit("/login/webauthn", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(signInsPerMinute, 60)) + // The access token carries `auth_time`, when the person behind it + // last signed in. The server records it on the sign-in; a refresh + // keeps the original, so a token kept alive for twelve hours still + // says the sign-in was twelve hours ago. Devices reads it: adding a + // signing key needs a sign-in minutes old, not just any token. + .authorizationServer(server -> server + .settings(AuthorizationServerSettings.builder() + .defaultAudience(AUDIENCE).build()) + .tokenCustomizer(context -> { + if (OAuth2TokenContext.ACCESS_TOKEN.equals(context.getTokenType()) + && context.getAuthorization() != null) { + Object at = context.getAuthorization().getAttribute("auth_time"); + if (at instanceof Number) { + context.getClaims().claim("auth_time", + Long.valueOf(((Number) at).longValue())); + } + // And how: `amr`, which the server records on + // the grant. A passkey sign-in (`hwk`) may add a + // device without retyping a password. + Object amr = context.getAuthorization().getAttribute("amr"); + if (amr instanceof java.util.List) { + context.getClaims().claim("amr", + new java.util.ArrayList((java.util.List) amr)); + } + } + })); + return http.build(); + } + + /// The API. Stateless: every request stands on its own bearer token. + @Bean + @Order(2) + public SecurityFilterChain api(HttpSecurity http, JwkSource signingKeys, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + @Value("${bastion.register.per-hour:20}") int registrationsPerHour, + @Value("${bastion.transfer.per-minute:20}") int movementsPerMinute, + @Value("${bastion.api.per-minute:300}") int callsPerMinute, + JdbcUserDetailsManager users) throws IOException { + DefaultJwtDecoder decoder = DefaultJwtDecoder.withJwkSource(signingKeys).build(); + decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + new JwtAudienceValidator(AUDIENCE), + issuer.length() > 0 ? new JwtIssuerValidator(issuer) + : new JwtClaimValidator("iss", SecurityConfig::issuedAtThisAddress))); + + // The token says who the caller is and nothing about what they may do. + // The roles are read from the account on every request, so an admin + // who suspends an account means now, not when the token runs out. + JwtAuthenticationConverter roles = new JwtAuthenticationConverter(); + roles.setJwtGrantedAuthoritiesConverter(jwt -> { + String name = jwt.getSubject(); + if (name == null || !users.userExists(name)) { + throw new DisabledException("No such account"); + } + UserDetails account = users.loadUserByUsername(name); + if (!account.isEnabled() || !account.isAccountNonLocked()) { + throw new DisabledException("This account is suspended"); + } + // Staff act on every customer's money and records, so a staff role + // is held only by a token whose sign-in used a second factor: an + // authenticator code (`otp`) or a passkey (`hwk`). A password-only + // staff token keeps what any account has -- enough to turn on an + // authenticator app -- and the console answers it 403. + List amr = jwt.getClaimAsStringList("amr"); + boolean second = amr != null && (amr.contains("otp") || amr.contains("hwk")); + Collection held = new ArrayList(); + for (GrantedAuthority a : account.getAuthorities()) { + String role = a.getAuthority(); + boolean staff = ("ROLE_" + Roles.ADMIN).equals(role) + || ("ROLE_" + Roles.AUDITOR).equals(role); + if (second || !staff) { + held.add(a); + } + } + return held; + }); + + http.securityMatcher("/api/**") + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // A JSON API is never framed and never a document: nothing it + // answers may load anything. + .headers(headers -> { + hardened(headers); + headers.contentSecurityPolicy(csp -> csp + .policyDirectives("default-src 'none'; frame-ancestors 'none'")); + }) + // Opening accounts costs a ledger row and a hash each, and a + // stranger may ask: per address. + .rateLimit("/api/account/register", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(registrationsPerHour, 3600)) + // Guessing the current password, or a code, with a token in hand: + // an hour's allowance is more than anyone honest needs. + .rateLimit("/api/account/password", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(10, 3600)) + .rateLimit("/api/account/mfa/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(10, 600)) + // Moving money, and the steps before it: a script with a stolen + // token and device key still moves it no faster than a person. + .rateLimit("/api/transfers/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(movementsPerMinute, 60)) + .rateLimit("/api/payees/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(movementsPerMinute, 60)) + .rateLimit("/api/devices/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(movementsPerMinute, 60)) + // The rest of what a customer's token reaches: orders, coin + // withdrawals and addresses, cards, accounts and their deposits. + // Reads are most of it -- a dashboard is a dozen calls -- so the + // allowance is a person's pace many times over, and a script + // with a stolen token is still held to it. + .rateLimit("/api/exchange/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(callsPerMinute, 60)) + .rateLimit("/api/crypto/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(callsPerMinute, 60)) + .rateLimit("/api/cards/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(callsPerMinute, 60)) + .rateLimit("/api/accounts/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(callsPerMinute, 60)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(AntPathRequestMatcher.antMatcher("POST", + "/api/account/register")).permitAll() + .requestMatchers("/api/admin/**").hasRole(Roles.ADMIN) + // Segregation of duties: the books and the audit trail + // are read by staff of either kind; only ADMIN operates. + .requestMatchers("/api/books/**", "/api/audit/**") + .hasAnyRole(Roles.ADMIN, Roles.AUDITOR) + .anyRequest().authenticated()) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(decoder) + .jwtAuthenticationConverter(roles))); + return http.build(); + } + + /// Records `action` when a request `matcher` takes is answered `status`: + /// for the steps whose filters have no handler of the application's. + static SecurityFilter audited(final RequestMatcher matcher, final int status, + final String action, final String detail, final Audit audit) { + return (request, chain) -> { + HttpServer.Response response = chain.doFilter(request); + if (response != null && response.getStatus() == status && matcher.matches(request)) { + String who = signedIn(); + audit.append(who, action, who, detail); + } + return response; + }; + } + + /// Who the request is from, or `unknown`: the second-factor step is + /// nobody yet as far as the chain knows. + private static String signedIn() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + return who == null || !who.isAuthenticated() || who.getName() == null ? "unknown" + : who.getName(); + } + + private static String[] split(String list) { + ArrayList out = new ArrayList(); + int start = 0; + while (start <= list.length()) { + int comma = list.indexOf(',', start); + int end = comma < 0 ? list.length() : comma; + String one = list.substring(start, end).trim(); + if (one.length() > 0) { + out.add(one); + } + start = end + 1; + } + return out.toArray(new String[out.size()]); + } + + /// Refuses a request that changes something and was sent by a page of + /// another site. A browser names the site a request came from in `Origin` + /// on every request that is not a plain read, and a page cannot change or + /// drop it. The installed app is not a browser and sends none. + static SecurityFilter sameOrigin(final String issuer) { + return (request, chain) -> { + String method = request.getMethod(); + if ("GET".equals(method) || "HEAD".equals(method) || "OPTIONS".equals(method)) { + return chain.doFilter(request); + } + String origin = request.getHeader("Origin"); + if (origin == null || origin.length() == 0) { + return chain.doFilter(request); + } + String here = here(issuer); + if (here != null && sameOrigin(origin, here)) { + return chain.doFilter(request); + } + return new HttpServer.Response(403, "application/json", + "{\"error\":\"cross_origin\"}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"); + }; + } + + /// This server's address: the configured issuer, and with none -- a + /// development profile -- the address the request in hand arrived at. + static String here(String issuer) { + if (issuer != null && issuer.length() > 0) { + return issuer; + } + SecurityExchange exchange = SecurityExchange.current(); + String host = exchange == null ? null : exchange.getRequest().getHeader("Host"); + if (host == null || host.length() == 0) { + return null; + } + return (exchange.isSecure() ? "https://" : "http://") + host; + } + + /// Whether `origin` is the origin of `address`, compared without regard to + /// case and without folding it, which would depend on the server's locale. + static boolean sameOrigin(String origin, String address) { + int path = address.indexOf('/', address.indexOf("://") + 3); + String root = path < 0 ? address : address.substring(0, path); + return origin.equalsIgnoreCase(root); + } + + /// Whether a token's issuer is the address this request came to; used + /// only when no issuer is configured, which only a development profile + /// allows. + static boolean issuedAtThisAddress(Object issuer) { + SecurityExchange exchange = SecurityExchange.current(); + if (exchange == null || !(issuer instanceof String)) { + return false; + } + String host = exchange.getRequest().getHeader("Host"); + return host != null && ((exchange.isSecure() ? "https://" : "http://") + host) + .equals(issuer); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/SignInCodePage.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/SignInCodePage.java new file mode 100644 index 00000000000..e4d2d230304 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/SignInCodePage.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// Where a sign-in from a browser ends: the authorization code, handed back. +/// +/// The installed app asks for its code to be sent to a loopback address and +/// reads it off the redirect without following it. A page in a browser cannot +/// do that -- the browser follows the redirect itself and shows the page only +/// what it found at the end -- so the app running in one asks for the code to +/// be sent here, and what is at the end is the code. +/// +/// Nothing is decided here and nothing is trusted: the code arrived in the +/// address, so repeating it tells the caller nothing it did not have, and it +/// is worth nothing without the PKCE verifier only the app that started the +/// sign-in holds. That is why this needs no sign-in of its own. +@RestController +public class SignInCodePage { + @GetMapping(SecurityConfig.WEB_REDIRECT_PATH) + public HttpServer.Response code( + @RequestParam(value = "code", required = false) String code, + @RequestParam(value = "state", required = false) String state, + @RequestParam(value = "error", required = false) String error) throws Exception { + Map answer = new LinkedHashMap(); + if (code != null) { + answer.put("code", code); + } + if (state != null) { + answer.put("state", state); + } + if (error != null) { + answer.put("error", error); + } + return new HttpServer.Response(code == null ? 400 : 200, "application/json", + Json.write(answer).getBytes("UTF-8")) + // A code is good once, for a few minutes; nothing should keep it. + .header("Cache-Control", "no-store") + // And nothing should read it as anything but data: the values + // come from the address, which anyone can write a link to. + .header("X-Content-Type-Options", "nosniff"); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/AccountEndpoint.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/AccountEndpoint.java new file mode 100644 index 00000000000..2c7cafc7405 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/AccountEndpoint.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.account; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.bastion.Caller; +import com.codenameone.examples.bastion.api.AccountApiServer; +import com.codenameone.examples.bastion.api.PasskeyDto; +import com.codenameone.examples.bastion.api.PasskeyRemoveDto; +import com.codenameone.examples.bastion.money.Devices; +import com.codenameone.examples.bastion.money.RequestSigning; +import com.codenameone.examples.bastion.api.PasswordChangeDto; +import com.codenameone.examples.bastion.api.RecoveryCodesDto; +import com.codenameone.examples.bastion.api.RegisterDto; +import com.codenameone.examples.bastion.api.TotpCodeDto; +import com.codenameone.examples.bastion.api.TotpEnrollmentDto; +import com.codenameone.examples.bastion.api.TotpStartDto; +import com.codenameone.examples.bastion.api.UserDto; + +import java.util.List; + +/// [com.codenameone.examples.bastion.api.AccountApi], served. Every call but +/// `register` acts on the caller's own account, named by their token and +/// never by the request. +@Component +public class AccountEndpoint implements AccountApiServer { + private final Accounts accounts; + private final SecondFactor secondFactor; + private final PasskeyList passkeys; + private final RequestSigning signing; + private final Devices devices; + + public AccountEndpoint(Accounts accounts, SecondFactor secondFactor, PasskeyList passkeys, + RequestSigning signing, Devices devices) { + this.signing = signing; + this.devices = devices; + this.accounts = accounts; + this.secondFactor = secondFactor; + this.passkeys = passkeys; + } + + @Override + public UserDto register(RegisterDto request) throws Exception { + return accounts.register(request); + } + + @Override + public UserDto me() throws Exception { + return accounts.describe(Caller.name()); + } + + @Override + public UserDto changePassword(PasswordChangeDto change) throws Exception { + return accounts.changePassword(Caller.name(), change == null ? null : change.current(), + change == null ? null : change.replacement()); + } + + @Override + public TotpEnrollmentDto startTotp(TotpStartDto start) throws Exception { + return secondFactor.start(Caller.name(), start == null ? null : start.password()); + } + + @Override + public RecoveryCodesDto confirmTotp(TotpCodeDto code) throws Exception { + return secondFactor.confirm(Caller.name(), code == null ? null : code.code(), + code == null ? null : code.password()); + } + + @Override + public RecoveryCodesDto newRecoveryCodes(TotpCodeDto code) throws Exception { + return secondFactor.renew(Caller.name(), code == null ? null : code.code()); + } + + @Override + public List passkeys() throws Exception { + return passkeys.of(Caller.name()); + } + + @Override + public List removePasskey(String id, String signature, PasskeyRemoveDto request) + throws Exception { + String owner = Caller.name(); + // The same step-up as revoking a device: a stolen token must not take + // away the passkeys the owner signs in with. A device of the account + // signs it or, with none at hand, the password is entered again. + if (RequestSigning.present()) { + signing.require(owner); + } else { + devices.recheck(owner, request == null ? null : request.password()); + } + return passkeys.remove(owner, id); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Accounts.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Accounts.java new file mode 100644 index 00000000000..e848b52fe4d --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Accounts.java @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.account; + +import java.time.Instant; +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.User; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.mfa.RecoveryCodeService; +import com.codename1.backend.security.mfa.TotpService; +import com.codenameone.examples.bastion.Roles; +import com.codenameone.examples.bastion.api.RegisterDto; +import com.codenameone.examples.bastion.api.UserDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.domain.Profile; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codenameone.examples.bastion.repository.ProfileRepository; + +import java.io.IOException; +import java.util.Iterator; + +/// Opening accounts, describing them, and changing their passwords. +/// +/// The sign-in itself -- the username, the password hash, the roles, whether +/// the account is enabled -- is the security layer's, in its own tables, +/// reached through [JdbcUserDetailsManager]. What the bank adds is a +/// [Profile] beside it and the ledger accounts that hold the money. +@Component +public class Accounts { + /// The asset and kind every new customer starts with. + public static final String FIRST_ASSET = "USD"; + + private final JdbcUserDetailsManager users; + private final PasswordEncoder encoder; + private final ProfileRepository profiles; + private final Ledger ledger; + private final Audit audit; + private final Grants grants; + private final TotpService totp; + private final RecoveryCodeService recoveryCodes; + private final SignInGuard guard; + + public Accounts(JdbcUserDetailsManager users, PasswordEncoder encoder, + ProfileRepository profiles, Ledger ledger, Audit audit, Grants grants, + TotpService totp, RecoveryCodeService recoveryCodes, SignInGuard guard) { + this.guard = guard; + this.users = users; + this.encoder = encoder; + this.profiles = profiles; + this.ledger = ledger; + this.audit = audit; + this.grants = grants; + this.totp = totp; + this.recoveryCodes = recoveryCodes; + } + + /// Opens a customer's account and a US dollar checking account with it, + /// in one transaction: there is never a customer with nowhere to put + /// money, nor a checking account with no customer. + public UserDto register(RegisterDto request) throws IOException { + try { + return open(request); + } catch (ResponseStatusException answered) { + throw answered; + } catch (IOException | RuntimeException failed) { + // Two registrations of one address at once both pass the check + // below and the second insert fails on the key. The transaction + // rolled back, so nothing is left of it; say what happened. + if (request != null && request.email() != null + && users.userExists(email(request.email()))) { + throw Refused.with(409, "e-mail-already-has-account", "That e-mail already has an account"); + } + throw failed; + } + } + + @Transactional(rollbackFor = Exception.class) + private UserDto open(RegisterDto request) throws IOException { + if (request == null) { + throw Refused.with(400, "nothing-register", "Nothing to register"); + } + String username = email(request.email()); + Passwords.check(username, request.password()); + String name = displayName(request.displayName()); + if (users.userExists(username)) { + // Saying so tells a stranger the address banks here. Every sign-up + // form does, since the alternative -- "check your e-mail" for both + // cases -- needs a mail service this sample does not have. The + // rate limit on this route is what keeps it from being a cheap + // directory of customers. + throw Refused.with(409, "e-mail-already-has-account", "That e-mail already has an account"); + } + create(username, request.password(), name, Roles.CUSTOMER, System.currentTimeMillis()); + LedgerAccount checking = ledger.open(username, FIRST_ASSET, AccountKinds.CHECKING, + "Checking"); + audit.append(username, "REGISTERED", username, "opened " + checking.getId()); + return describe(username); + } + + /// Makes the sign-in and the profile, holding exactly one of the + /// [Roles]: staff do not bank on their staff account, and an auditor + /// is never also an administrator. + @Transactional(rollbackFor = Exception.class) + void create(String username, String password, String displayName, String role, + long openedAt) throws IOException { + users.createUser(User.withUsername(username).password(encoder.encode(password)) + .roles(role).build()); + Profile profile = new Profile(); + profile.setUsername(username); + profile.setDisplayName(displayName); + profile.setCreatedAt(Instant.ofEpochMilli(openedAt)); + profiles.insert(profile); + } + + public boolean exists(String username) { + return users.userExists(username); + } + + @Transactional(readOnly = true) + public UserDto describe(String username) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null || !users.userExists(username)) { + throw Refused.with(404, "no-such-account", "No such account"); + } + UserDetails signIn = users.loadUserByUsername(username); + boolean customer = false; + boolean admin = false; + boolean auditor = false; + Iterator it = signIn.getAuthorities().iterator(); + while (it.hasNext()) { + String held = it.next().getAuthority(); + customer |= ("ROLE_" + Roles.CUSTOMER).equals(held); + admin |= ("ROLE_" + Roles.ADMIN).equals(held); + auditor |= ("ROLE_" + Roles.AUDITOR).equals(held); + } + boolean second = totp.isEnabled(username); + return new UserDto(username, profile.getDisplayName(), customer, admin, second, + second ? recoveryCodes.remaining(username) : 0, profile.getCreatedAt().toEpochMilli(), auditor); + } + + public UserDto changePassword(String username, String current, String replacement) + throws IOException { + // The current password is a password guess like any other: held to + // the sign-in lockout (a locked account is refused before it is looked + // at, a wrong one counts), or a stolen token would be an oracle with + // unlimited guesses. Audited outside the change's transaction, which a + // refusal rolls back. + guard.recheck(username, current, encoder, "PASSWORD_CHANGE_REFUSED", + "not-current-password", "That is not your current password"); + if (!replace(username, current, replacement)) { + // Changed by a concurrent request after the check above: still a + // wrong password for this one, and counted as one. + guard.failed(username); + audit.append(username, "PASSWORD_CHANGE_REFUSED", username, "wrong current password"); + throw Refused.with(403, "not-current-password", "That is not your current password"); + } + return describe(username); + } + + /// Changes the password, or answers false when `current` is not it. + @Transactional(rollbackFor = Exception.class) + private boolean replace(String username, String current, String replacement) + throws IOException { + // Before the password is read. Two changes at once would otherwise + // both check the same old password and both succeed: whoever else + // knows the old one could race the owner's change and keep the + // account. Behind the hold the second checks the password the first set. + profiles.hold(username); + UserDetails signIn = users.loadUserByUsername(username); + if (current == null || !encoder.matches(current, signIn.getPassword())) { + return false; + } + Passwords.check(username, replacement); + users.updatePassword(signIn, encoder.encode(replacement)); + // A password is changed, as often as not, because somebody else may + // have the old one or a device signed in with it. A refresh token asks + // for no password, so every sign-in but this one ends here. + grants.endOthers(username); + audit.append(username, "PASSWORD_CHANGED", username, ""); + return true; + } + + /// An e-mail address as the username it is stored under: trimmed, and the + /// ASCII letters folded to lower case by hand. `toLowerCase` would fold in + /// the server's language, and under a Turkish locale `I` is not `i`. + public static String email(String value) { + String text = value == null ? "" : value.trim(); + int at = text.indexOf('@'); + if (at < 1 || at != text.lastIndexOf('@') || at > text.length() - 4 + || text.indexOf('.', at) < 0 || text.length() > 190) { + throw Refused.with(400, "not-e-mail-address", "That is not an e-mail address"); + } + String folded = asciiLower(text); + for (int iter = 0; iter < folded.length(); iter++) { + char c = folded.charAt(iter); + if (c <= ' ' || c > '~') { + throw Refused.with(400, "not-e-mail-address", "That is not an e-mail address"); + } + } + return folded; + } + + /// `text` with A-Z folded to a-z and nothing else touched. + static String asciiLower(String text) { + char[] chars = text.toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + char c = chars[iter]; + if (c >= 'A' && c <= 'Z') { + chars[iter] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } + + private static String displayName(String value) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + throw Refused.with(400, "tell-us-name", "Tell us your name"); + } + if (text.length() > 120) { + throw Refused.with(400, "name-too-long", "That name is too long"); + } + return text; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/DemoAccounts.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/DemoAccounts.java new file mode 100644 index 00000000000..f6a86a59516 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/DemoAccounts.java @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.account; + +import com.codename1.backend.Config; +import com.codename1.backend.security.mfa.JdbcTotpRepository; +import com.codename1.security.Base32; +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.bastion.Roles; +import com.codenameone.examples.bastion.books.Books; +import com.codenameone.examples.bastion.chain.Chain; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.exchange.Markets; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Leg; +import com.codenameone.examples.bastion.ledger.Ledger; + +import java.io.IOException; +import java.util.Arrays; + +/// The accounts a server starts with. +/// +/// Outside a development profile: one administrator, from +/// `bastion.admin.email` and `bastion.admin.password`, and only when both are +/// set -- in the environment, never in a file that is committed. No request +/// can make an administrator, so this is the only way one comes to exist. +/// +/// On a development profile (`dev`, and `test` for the tests): demo customers +/// with weeks of history (see [DemoHistory], `bastion.demo.history-days`), +/// two admins and an auditor, all with [#PASSWORD]. Those passwords are in this file for anyone to read, which is +/// why they exist nowhere else. +@Component +public class DemoAccounts { + public static final String CUSTOMER = "customer@bastion.example"; + public static final String SECOND_CUSTOMER = "customer2@bastion.example"; + public static final String ADMIN = "admin@bastion.example"; + /// A second administrator, so maker-checker can be shown: one makes an + /// adjustment above the threshold, the other approves it. + public static final String SECOND_ADMIN = "admin2@bastion.example"; + /// Reads the books and the audit trail, and can change nothing. + public static final String AUDITOR = "auditor@bastion.example"; + public static final String PASSWORD = "bastion-demo-password"; + /// The demo staff's authenticator-app secret, base32 as an app takes it. + /// Staff need a second factor to use the console (see SecurityConfig), so + /// the demo staff have one, and it is public for the same reason the + /// password is: these accounts exist only on a development profile. + public static final String STAFF_TOTP_SECRET = "BASTIONDEMOSTAFFAUTHENTICATOR234"; + + private final Accounts accounts; + private final Ledger ledger; + private final Config config; + private final String adminEmail; + private final String adminPassword; + private final Markets markets; + private final DataSource db; + private final Books books; + private final int historyDays; + + /// Asked which coins are real: the demo never mints those. + private final Chain chain; + + public DemoAccounts(Accounts accounts, Ledger ledger, Config config, Markets markets, + DataSource db, Books books, Chain chain, + @Value("${bastion.admin.email:}") String adminEmail, + @Value("${bastion.admin.password:}") String adminPassword, + @Value("${bastion.demo.history-days:42}") String historyDays) { + this.markets = markets; + this.db = db; + this.books = books; + this.chain = chain; + this.historyDays = Math.max(0, Integer.parseInt(historyDays.trim())); + this.accounts = accounts; + this.ledger = ledger; + this.config = config; + this.adminEmail = adminEmail; + this.adminPassword = adminPassword; + } + + @PostConstruct + public void seed() throws IOException { + if (adminEmail.length() > 0 && adminPassword.length() > 0) { + String username = Accounts.email(adminEmail); + // The same policy as anyone's: a weak admin password fails the + // start-up rather than guarding the audit log. + Passwords.check(username, adminPassword); + staff(username, adminPassword, Roles.ADMIN, "Administrator"); + } + if (!config.isDevelopmentProfile()) { + return; + } + staff(ADMIN, PASSWORD, Roles.ADMIN, "Administrator"); + staff(SECOND_ADMIN, PASSWORD, Roles.ADMIN, "Second Administrator"); + staff(AUDITOR, PASSWORD, Roles.AUDITOR, "Internal Auditor"); + JdbcTotpRepository apps = JdbcTotpRepository.fromConfig(db, config); + for (String who : new String[] {ADMIN, SECOND_ADMIN, AUDITOR}) { + if (apps.find(who) == null) { + apps.save(who, Base32.decode(STAFF_TOTP_SECRET)); + apps.confirm(who); + } + } + boolean fresh = !accounts.exists(CUSTOMER); + long opened = DemoHistory.start(historyDays) - 3600000L; + customer(CUSTOMER, "Riley Customer", 250000, opened); + customer(SECOND_CUSTOMER, "Robin Customer", 120000, opened); + if (fresh && historyDays > 0) { + new DemoHistory(ledger, markets, db, chain).seed(historyDays, CUSTOMER, + SECOND_CUSTOMER); + if (historyDays > 35 && ledger.closedThrough() == 0) { + // The first four weeks as a closed period, so the demo shows + // closing entries and retained earnings. + books.close(AccountKinds.BANK, DemoHistory.start(historyDays) + 28 * Books.DAY + - 9 * 3600000L - 1); + } + } + } + + private void staff(String username, String password, String role, String name) + throws IOException { + if (accounts.exists(username)) { + return; + } + try { + accounts.create(username, password, name, role, System.currentTimeMillis()); + } catch (IOException | RuntimeException failed) { + // Two servers started together against a new database both find + // no admin and both make one; the second insert fails on the name. + // create() is one transaction, so the loser left nothing behind. + if (!accounts.exists(username)) { + throw failed; + } + } + } + + private void customer(String username, String name, long cents, long at) throws IOException { + if (accounts.exists(username)) { + return; + } + accounts.create(username, PASSWORD, name, Roles.CUSTOMER, at); + LedgerAccount checking = ledger.open(username, Accounts.FIRST_ASSET, + AccountKinds.CHECKING, "Checking"); + // Money comes from the bank's cash account, as a deposit at a branch + // would: the books balance from the first entry. The key makes a + // second seeding of the same account a no-op. + LedgerAccount cash = ledger.system(Accounts.FIRST_ASSET, AccountKinds.SYS_CASH); + // Dated at the start of the demo history, which follows it. + ledger.postAt("seed-" + username, "DEPOSIT", AccountKinds.BANK, "Opening deposit", + Arrays.asList(new Leg(cash.getId(), -cents), new Leg(checking.getId(), cents)), + at); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/DemoHistory.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/DemoHistory.java new file mode 100644 index 00000000000..3bb0f6e0e04 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/DemoHistory.java @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.account; + +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.books.Books; +import com.codenameone.examples.bastion.chain.Chain; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.domain.Market; +import com.codenameone.examples.bastion.exchange.MarketMaker; +import com.codenameone.examples.bastion.exchange.Markets; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Leg; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codename1.backend.DataSource; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +/// Weeks of believable history for a development server, so that every +/// chart, statement and ledger has something to show: payroll, card spending, +/// rent, savings with monthly interest, wire fees, and a trade tape on every +/// configured crypto market between the market maker, the demo customers and +/// two other trading desks. +/// +/// Everything is posted through the ledger, dated in the past +/// ([Ledger#postAt]) and in time order, so the books balance at every instant +/// and no balance is ever below zero on any day. The bank's capital comes in +/// first. A pseudo-random generator with a fixed seed makes every fresh +/// database tell the same story. When the history spans more than five +/// weeks, its first four are closed as an accounting period. +final class DemoHistory { + private static final long DAY = Books.DAY; + private static final long HOUR = 3600000L; + private static final String[] MERCHANTS = { + "Corner Grocer", "Metro Transit", "Blue Bottle Coffee", "City Power & Light", + "Northside Pharmacy", "Bookshop Annex", "Harbor Fuel", "Lantern Bistro", + "Streamly Subscription", "Hardware Depot", + }; + /// Trading desks other than the demo customers, so the tape is not two + /// people trading with a robot. Like the market maker they are not + /// usernames anyone can sign in as. + static final String[] DESKS = {"@desk-north", "@desk-south"}; + + private final Ledger ledger; + private final Markets markets; + private final DataSource db; + private final Random random = new Random(20261021L); + private int seq; + + private final Chain chain; + + DemoHistory(Ledger ledger, Markets markets, DataSource db, Chain chain) { + this.chain = chain; + this.ledger = ledger; + this.markets = markets; + this.db = db; + } + + /// The time the history starts: the demo customers' opening deposits are + /// dated here too. + static long start(int days) { + return Books.day(System.currentTimeMillis()) - days * DAY + 9 * HOUR; + } + + void seed(int days, String customer, String second) throws IOException { + long start = start(days); + LedgerAccount cash = ledger.system("USD", AccountKinds.SYS_CASH); + LedgerAccount equity = ledger.system("USD", AccountKinds.SYS_EQUITY); + post("capital", "CAPITAL", AccountKinds.BANK, "Paid-in capital", start - HOUR, + new Leg(cash.getId(), -100000000L), new Leg(equity.getId(), 100000000L)); + + LedgerAccount checking = first(customer, "USD", AccountKinds.CHECKING); + LedgerAccount savings = ledger.open(customer, "USD", AccountKinds.SAVINGS, "Savings"); + LedgerAccount checking2 = first(second, "USD", AccountKinds.CHECKING); + LedgerAccount fees = ledger.system("USD", AccountKinds.SYS_FEES); + LedgerAccount interest = ledger.system("USD", AccountKinds.SYS_INTEREST); + + List tape = new ArrayList(); + for (Market m : markets.all()) { + // The configured markets, not the tests' T1..T9: one per pair. + // A coin on a real chain gets no history: every unit of it in the + // ledger must be a deposit the chain can show. + if (m.getId().equals(m.getBase() + "-" + m.getQuote()) && !chain.handles(m.getBase())) { + tape.add(m); + } + } + List traders = new ArrayList(Arrays.asList(customer, second)); + traders.addAll(Arrays.asList(DESKS)); + for (Market m : tape) { + fund(MarketMaker.OWNER, m, 2000, start); + for (String trader : traders) { + fund(trader, m, trader.startsWith("@") ? 40 : 1, start); + } + } + long[] price = new long[tape.size()]; + for (int i = 0; i < tape.size(); i++) { + price[i] = tape.get(i).getIndexPrice(); + } + + for (int d = 0; d < days; d++) { + long day = start + d * DAY; + if (d % 14 == 1) { + move("payroll", "DEPOSIT", customer, "Payroll - Northwind Ltd", day + HOUR, + cash, checking, 420000); + } + if (d % 14 == 8) { + move("payroll", "DEPOSIT", second, "Payroll - Contoso Labs", day + HOUR, + cash, checking2, 310000); + } + int spends = 1 + random.nextInt(3); + for (int s = 0; s < spends; s++) { + long cents = 800 + random.nextInt(11200); + move("card", "CARD", customer, MERCHANTS[random.nextInt(MERCHANTS.length)], + day + (3 + 3 * s) * HOUR + random.nextInt(3600000), checking, cash, cents); + if (random.nextInt(2) == 0) { + move("card", "CARD", second, MERCHANTS[random.nextInt(MERCHANTS.length)], + day + (4 + 3 * s) * HOUR, checking2, cash, 500 + random.nextInt(6000)); + } + } + if (d % 7 == 3) { + move("rent", "TRANSFER", customer, "Rent share", day + 10 * HOUR, checking, + checking2, 45000); + } + if (d % 7 == 5) { + move("save", "TRANSFER", customer, "To savings", day + 11 * HOUR, checking, + savings, 30000); + } + if (d % 10 == 6) { + long at = day + 12 * HOUR; + post("wire", "WITHDRAWAL", customer, "Wire to brokerage, fee 15.00", at, + new Leg(checking.getId(), -21500), new Leg(cash.getId(), 20000), + new Leg(fees.getId(), 1500)); + } + if (d % 30 == 29) { + long held = Math.max(0L, ledger.owned(customer, savings.getId()).getBalance()); + long paid = Math.max(1L, held * 40 / 10000); + move("interest", "INTEREST", AccountKinds.BANK, "Savings interest 0.40%", + day + 23 * HOUR, interest, savings, paid); + } + for (int i = 0; i < tape.size(); i++) { + Market m = tape.get(i); + // The last two days trade densely, so that every hour of the + // exchange's 24-hour candles has an open, a close and a range. + int count = d >= days - 2 ? 140 + random.nextInt(40) : 10 + random.nextInt(10); + for (int t = 0; t < count; t++) { + // A mean-reverting random walk around the index price. + long index = m.getIndexPrice(); + long drift = (index - price[i]) / 20; + long shock = price[i] * (random.nextInt(2001) - 1000) / 100000; + price[i] = Math.max(index / 2, price[i] + drift + shock); + String trader = traders.get(random.nextInt(traders.size())); + trade(m, price[i], trader, random.nextBoolean(), + day + (t * 24 * HOUR) / count + random.nextInt(600000)); + } + } + } + } + + /// Opens `owner`'s accounts for `m` if missing and deposits `units` whole + /// coins; a desk or the market maker also gets that many coins' worth of + /// quote currency as a float. + private void fund(String owner, Market m, int units, long at) throws IOException { + LedgerAccount wallet = first(owner, m.getBase(), AccountKinds.WALLET); + LedgerAccount checking = first(owner, m.getQuote(), AccountKinds.CHECKING); + long coin = units * Markets.scale(ledger.asset(m.getBase())); + LedgerAccount chain = ledger.system(m.getBase(), AccountKinds.SYS_CHAIN); + post("fund-" + m.getBase(), "DEPOSIT", owner, "Coins deposited", at, new Leg(chain.getId(), + -coin), new Leg(wallet.getId(), coin)); + if (owner.startsWith("@")) { + long quote = (long) units * m.getIndexPrice(); + LedgerAccount cash = ledger.system(m.getQuote(), AccountKinds.SYS_CASH); + post("fund-" + m.getQuote(), "DEPOSIT", owner, "Trading float", at, + new Leg(cash.getId(), -quote), new Leg(checking.getId(), quote)); + } + } + + /// One trade between `trader` and the market maker, the trader taking: + /// the same five legs the matching engine posts, taker fee 0.2%. + private void trade(Market m, long price, String trader, boolean buys, long at) + throws IOException { + long scale = Markets.scale(ledger.asset(m.getBase())); + long lots = 1 + random.nextInt(trader.startsWith("@") ? 400 : 60); + long quantity = lots * m.getLot(); + long notional = price * quantity / scale; + if (notional <= 0) { + return; + } + long fee = Math.max(1L, notional * 20 / 10000); + LedgerAccount tWallet = first(trader, m.getBase(), AccountKinds.WALLET); + LedgerAccount tCash = first(trader, m.getQuote(), AccountKinds.CHECKING); + LedgerAccount mWallet = first(MarketMaker.OWNER, m.getBase(), AccountKinds.WALLET); + LedgerAccount mCash = first(MarketMaker.OWNER, m.getQuote(), AccountKinds.CHECKING); + LedgerAccount fees = ledger.system(m.getQuote(), AccountKinds.SYS_FEES); + List legs = buys + ? Arrays.asList(new Leg(tCash.getId(), -(notional + fee)), + new Leg(mCash.getId(), notional), new Leg(fees.getId(), fee), + new Leg(mWallet.getId(), -quantity), new Leg(tWallet.getId(), quantity)) + : Arrays.asList(new Leg(tWallet.getId(), -quantity), + new Leg(mWallet.getId(), quantity), new Leg(mCash.getId(), -notional), + new Leg(tCash.getId(), notional - fee), new Leg(fees.getId(), fee)); + String entry = post("trade", "TRADE", trader, (buys ? "Buy " : "Sell ") + m.getId(), at, + legs.toArray(new Leg[0])); + if (entry == null) { + return; + } + // Written straight to the table: this runs outside any transaction, + // where a repository has no session to write through. + db.execute("INSERT INTO trade (id, market, buy_order, sell_order, buyer, seller, " + + "taker_side, price, quantity, buyer_paid, seller_got, fee, entry_id, created_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", new Object[] {Ids.next(), + m.getId(), "", "", buys ? trader : MarketMaker.OWNER, + buys ? MarketMaker.OWNER : trader, buys ? "BUY" : "SELL", Long.valueOf(price), + Long.valueOf(quantity), Long.valueOf(buys ? notional + fee : notional), + Long.valueOf(buys ? notional : notional - fee), Long.valueOf(fee), entry, + Long.valueOf(at)}); + } + + private void move(String tag, String kind, String initiator, String memo, long at, + LedgerAccount from, LedgerAccount to, long amount) throws IOException { + post(tag, kind, initiator, memo, at, new Leg(from.getId(), -amount), + new Leg(to.getId(), amount)); + } + + /// Posts and answers the entry id, or null when the ledger refused it -- + /// a customer the walk would overdraw simply does not make that payment. + private String post(String tag, String kind, String initiator, String memo, long at, + Leg... legs) throws IOException { + try { + return ledger.postAt("demo-history:" + tag + ":" + (seq++), kind, initiator, memo, + Arrays.asList(legs), at).getId(); + } catch (RuntimeException refused) { + return null; + } + } + + private LedgerAccount first(String owner, String asset, String kind) throws IOException { + for (LedgerAccount a : ledger.accountsOf(owner)) { + if (a.getAsset().equals(asset) && a.getKind().equals(kind)) { + return a; + } + } + return ledger.open(owner, asset, kind, AccountKinds.WALLET.equals(kind) + ? asset + " wallet" : "Checking"); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Grants.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Grants.java new file mode 100644 index 00000000000..aa07b9fd00e --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Grants.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.account; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.SecurityContextHolder; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationToken; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/// The sign-ins an account has open, and ending them. +/// +/// Signing in leaves a grant in the authorization server's own table, and a +/// refresh token is good for as long as its grant is there: refreshing asks +/// for no password. So a password changed because a phone was lost changes +/// nothing for whoever holds that phone, unless the grants go too. +/// +/// An access token already issued is not recalled by this. It is a signed +/// token that is checked without the table, and it runs out on its own within +/// its few minutes; what ends here is the means of getting another. +@Component +public class Grants { + private final DataSource db; + private final OAuth2AuthorizationService authorizations; + + public Grants(DataSource db, OAuth2AuthorizationService authorizations) { + this.db = db; + this.authorizations = authorizations; + } + + /// Ends every sign-in of an account but the one this request was made + /// with, so the device a password was changed on stays signed in and every + /// other has to give the new password. + public void endOthers(String username) throws IOException { + end(username, current()); + } + + /// Ends every sign-in of an account. + public void endAll(String username) throws IOException { + end(username, ""); + } + + private void end(String username, String kept) throws IOException { + // The table is the authorization server's, which offers a grant by its + // id and no list of an account's: this reads the ids and leaves the + // removing, which takes the grant's tokens with it, to the service. + List rows = db.query("SELECT id FROM cn1_oauth2_authorization WHERE principal_name = ?", + new Object[] {username}); + for (int iter = 0; rows != null && iter < rows.size(); iter++) { + Object row = rows.get(iter); + Object id = row instanceof Map ? ((Map) row).get("id") : null; + if (id != null && !kept.equals(String.valueOf(id))) { + authorizations.remove(String.valueOf(id)); + } + } + } + + /// The id of the grant the request's token was issued under, or empty for + /// a request that carried no such token. The server writes the grant's id + /// before the first dot of the token's own. + private static String current() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (!(who instanceof JwtAuthenticationToken)) { + return ""; + } + String id = ((JwtAuthenticationToken) who).getToken().getId(); + int dot = id == null ? -1 : id.indexOf('.'); + return dot > 0 ? id.substring(0, dot) : ""; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/PasskeyList.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/PasskeyList.java new file mode 100644 index 00000000000..4f228539981 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/PasskeyList.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.account; + +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.Base64Url; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.security.webauthn.CredentialRecord; +import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntity; +import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntityRepository; +import com.codename1.backend.security.webauthn.UserCredentialRepository; +import com.codenameone.examples.bastion.api.PasskeyDto; +import com.codenameone.examples.bastion.audit.Audit; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/// The passkeys on an account, for the app's Settings screen. +/// +/// Adding one stays a WebAuthn ceremony (`/webauthn/register`, on the +/// signed-in session). Listing needs only the caller's token. Removing one +/// needs more: [AccountEndpoint] asks for a signature from one of the +/// account's devices or, with none at hand, the password again -- the same +/// step-up as revoking a device -- so a stolen token alone cannot take away +/// the passkeys the owner signs in with. Every removal is in the audit log. +@Component +public class PasskeyList { + private final UserCredentialRepository credentials; + private final PublicKeyCredentialUserEntityRepository holders; + private final Audit audit; + + public PasskeyList(UserCredentialRepository credentials, + PublicKeyCredentialUserEntityRepository holders, Audit audit) { + this.credentials = credentials; + this.holders = holders; + this.audit = audit; + } + + /// `username`'s passkeys, oldest first. + public List of(String username) { + List out = new ArrayList(); + PublicKeyCredentialUserEntity holder = holders.findByUsername(username); + if (holder == null) { + return out; + } + for (CredentialRecord r : credentials.findByUserId(holder.getId())) { + out.add(new PasskeyDto(Base64Url.encode(r.getCredentialId()), r.getLabel(), + r.getCreated(), r.getLastUsed())); + } + return out; + } + + /// Removes `username`'s passkey `id`; 404 for one that is not theirs, so + /// another account's ids cannot be probed. + public List remove(String username, String id) throws IOException { + byte[] credentialId = decode(id); + PublicKeyCredentialUserEntity holder = holders.findByUsername(username); + CredentialRecord record = credentialId == null || holder == null + ? null : credentials.findByCredentialId(credentialId); + if (record == null || !Arrays.equals(record.getUserEntityUserId(), holder.getId())) { + throw Refused.with(404, "no-such-passkey", "No such passkey"); + } + credentials.delete(credentialId); + audit.append(username, "PASSKEY_REMOVED", username, record.getLabel()); + return of(username); + } + + private static byte[] decode(String id) { + try { + return id == null ? null : Base64Url.decode(id); + } catch (RuntimeException notBase64) { + return null; + } + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Passwords.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Passwords.java new file mode 100644 index 00000000000..ca8f8170672 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Passwords.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.account; + +import com.codenameone.examples.bastion.Refused; + +/// What a password must be. +/// +/// Length is the rule that matters, and the one NIST SP 800-63B asks for: +/// twelve characters at the least, and no rules about mixing classes of +/// character, which push people to `Password1!`. The password may not be the +/// username, nor the part of the e-mail address before the `@`, since that is +/// the first thing anyone guesses. A real bank would also refuse passwords +/// that appear in breach corpora (a k-anonymity lookup such as Have I Been +/// Pwned's range API); that needs a network call this sample does not make. +public final class Passwords { + public static final int MINIMUM = 12; + /// The hash is PBKDF2 or bcrypt, both of which cost the same for any + /// length -- bcrypt reads only 72 bytes -- but a megabyte of password is + /// still a megabyte to parse. + public static final int MAXIMUM = 200; + + private Passwords() { + } + + /// Refuses `password` for `username` with a 400 that says why. + public static void check(String username, String password) { + if (password == null || password.length() < MINIMUM) { + throw Refused.with(400, "choose-password-least-characters", "Choose a password of at least {0} characters", MINIMUM); + } + if (password.length() > MAXIMUM) { + throw Refused.with(400, "password-too-long", "That password is too long"); + } + if (username != null) { + int at = username.indexOf('@'); + String local = at > 0 ? username.substring(0, at) : username; + // equalsIgnoreCase compares character by character, without + // folding the whole string in the server's language. + if (password.trim().equalsIgnoreCase(username) + || password.trim().equalsIgnoreCase(local)) { + throw Refused.with(400, "password-cannot-e-mail-address", "Your password cannot be your e-mail address"); + } + } + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/SecondFactor.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/SecondFactor.java new file mode 100644 index 00000000000..63414dad946 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/SecondFactor.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.account; + +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.security.mfa.RecoveryCodeService; +import com.codename1.backend.security.mfa.TotpEnrollment; +import com.codename1.backend.security.mfa.TotpService; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codenameone.examples.bastion.Caller; +import com.codenameone.examples.bastion.api.RecoveryCodesDto; +import com.codenameone.examples.bastion.api.TotpEnrollmentDto; +import com.codenameone.examples.bastion.audit.Audit; + +import java.io.IOException; + +/// Adding an authenticator app to an account, and the recovery codes that +/// stand in for it. +/// +/// Once an account has a confirmed app, `http.mfa(...)` in +/// [com.codenameone.examples.bastion.SecurityConfig] holds every password +/// sign-in back until a code from it is posted to `/login/mfa`. The codes are +/// checked, used up and counted by [TotpService]; nothing here sees one at +/// sign-in. +@Component +public class SecondFactor { + private final TotpService totp; + private final RecoveryCodeService recoveryCodes; + private final Audit audit; + + private final JdbcUserDetailsManager users; + private final PasswordEncoder encoder; + private final SignInGuard guard; + private final long freshSeconds; + + public SecondFactor(TotpService totp, RecoveryCodeService recoveryCodes, Audit audit, + JdbcUserDetailsManager users, PasswordEncoder encoder, SignInGuard guard, + @Value("${bastion.device.fresh-minutes:10}") String freshMinutes) { + this.users = users; + this.encoder = encoder; + this.guard = guard; + this.totp = totp; + this.recoveryCodes = recoveryCodes; + this.audit = audit; + this.freshSeconds = Long.parseLong(freshMinutes.trim()) * 60L; + } + + /// Enrolling an app is a step-up: once it is on, every sign-in needs its + /// codes, so a stolen token must not be able to put the attacker's app on + /// the account and lock the customer out. Every caller's sign-in must be + /// fresh (as for a device), however it was made: a strong sign-in hours + /// old is a session somebody may have walked away from. A sign-in made + /// with a second factor or a user-verified passkey (`amr` holding `mfa`, + /// `otp`, or `hwk` together with `user`) then skips only the password. A password-only one must also + /// carry the password: freshness alone would let a token stolen in its + /// first minutes enrol, which is exactly the window a phishing proxy + /// works in. A wrong password counts toward the lockout. + private void requireStepUp(String username, String password) throws IOException { + long age = System.currentTimeMillis() / 1000L - Caller.authTime(); + if (Caller.authTime() < 0 || age > freshSeconds || age < -60L) { + audit.append(username, "TOTP_REFUSED", username, "sign-in not fresh"); + throw Refused.with(403, "sign-in-first", "Sign in first"); + } + java.util.List how = Caller.authMethods(); + // As in Devices: a passkey counts only when the user was verified. + if (how.contains("mfa") || how.contains("otp") + || (how.contains("hwk") && how.contains("user"))) { + return; + } + guard.recheck(username, password, encoder, "TOTP_REFUSED", + "enter-password-add-app", "Enter your password to add an authenticator app"); + } + + /// Makes a secret for the caller's app. Refused when they already have + /// one: starting over replaces the secret and turns the second factor off + /// until it is confirmed again, which would let somebody holding only an + /// access token strip the account of its second factor. + public TotpEnrollmentDto start(String username, String password) throws IOException { + if (totp.isEnabled(username)) { + throw Refused.with(409, "account-already-has-authenticator-app", "This account already has an authenticator app"); + } + requireStepUp(username, password); + TotpEnrollment enrollment = totp.beginEnrollment(username); + audit.append(username, "TOTP_STARTED", username, ""); + return new TotpEnrollmentDto(enrollment.getSecret(), enrollment.getOtpauthUri()); + } + + /// Confirms the app with a code it shows, and makes the recovery codes. + public RecoveryCodesDto confirm(String username, String code, String password) + throws IOException { + requireStepUp(username, password); + if (code == null || !totp.confirmEnrollment(username, code)) { + audit.append(username, "TOTP_CONFIRM_FAILED", username, ""); + throw Refused.with(400, "code-not-app-shows", "That code is not the one your app shows"); + } + audit.append(username, "TOTP_ENROLLED", username, ""); + return new RecoveryCodesDto(recoveryCodes.generate(username)); + } + + /// New recovery codes in place of the old, for a caller who proves they + /// still have the app. + public RecoveryCodesDto renew(String username, String code) throws IOException { + if (!totp.isEnabled(username)) { + throw Refused.with(409, "add-authenticator-app", "Add an authenticator app first"); + } + if (code == null || !totp.verify(username, code)) { + audit.append(username, "RECOVERY_CODES_REFUSED", username, ""); + throw Refused.with(403, "code-not-app-shows", "That code is not the one your app shows"); + } + audit.append(username, "RECOVERY_CODES_RENEWED", username, ""); + return new RecoveryCodesDto(recoveryCodes.generate(username)); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/SignInGuard.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/SignInGuard.java new file mode 100644 index 00000000000..54b535b474a --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/SignInGuard.java @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.account; + +import java.time.Instant; +import com.codename1.backend.HttpServer; +import com.codename1.backend.HttpSession; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.security.AntPathRequestMatcher; +import com.codename1.backend.security.AuthenticationFailureHandler; +import com.codename1.backend.security.AuthenticationSuccessHandler; +import com.codename1.backend.security.RequestMatcher; +import com.codename1.backend.security.SecondFactorAuthenticationFilter; +import com.codename1.backend.security.SecurityFilter; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.mfa.RecoveryCodeService; +import com.codename1.backend.security.mfa.TotpService; +import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntity; +import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntityRepository; +import com.codename1.backend.security.webauthn.UserCredentialRepository; +import com.codenameone.examples.bastion.Refused; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.LoginFailure; +import com.codenameone.examples.bastion.repository.LoginFailureRepository; + +import java.io.IOException; +import java.util.Map; + +/// What happens around a sign-in: the audit record of each one, and the +/// lockout after repeated wrong passwords. +/// +/// ## The lockout +/// +/// The rate limit on `/login` counts by client address, which an attacker +/// with many addresses walks around. This counts by account: after +/// `bastion.lockout.attempts` wrong passwords (5) within +/// `bastion.lockout.window-seconds` (15 minutes), the account refuses +/// password sign-ins for `bastion.lockout.seconds` (15 minutes), the right +/// password included. The security layer has no lockout of its own -- it +/// honours `account_non_locked`, but nothing sets it -- and a permanent lock +/// there would need an administrator to lift every time, so the count and the +/// lock live in a table of this application's. +/// +/// The trade every lockout makes: anyone who knows a customer's e-mail can +/// keep them from signing in with a password, five guesses every fifteen +/// minutes. That is why the lock is temporary and why it covers only +/// passwords -- a passkey still signs the customer in, and an attacker +/// cannot lock that out. It also says, to whoever is guessing, that the +/// account exists; registration says as much already. +/// +/// ## The strongest factor an account has is the one it signs in with +/// +/// An account is only as safe as the weakest way into it, so enrolling a +/// stronger factor retires the weaker ones. With a passkey registered the +/// password stops being a way in: `POST /login` is refused (403, +/// `"stronger":"passkey"`) before the password is looked at, and the code +/// step that follows a password takes a recovery code and nothing else -- a +/// six-digit code from the authenticator app is refused the same way. That +/// leaves one documented way back for a customer whose passkey is gone: their +/// password and one of their recovery codes, each of which works once, and +/// the sign-in is in the audit log as `password+totp` with the passkey still +/// on the account. An account with a passkey and no authenticator app has no +/// recovery codes, and its way back is the bank, which removes the passkey +/// after checking who is asking. The refusal says the account has a passkey +/// to anyone who knows its e-mail, the same trade the lockout makes. +@Component +public class SignInGuard { + private static final RequestMatcher PASSWORD_SIGN_IN = + AntPathRequestMatcher.antMatcher("POST", "/login"); + private static final RequestMatcher SECOND_FACTOR = + AntPathRequestMatcher.antMatcher("POST", "/login/mfa"); + + private final LoginFailureRepository failures; + private final JdbcUserDetailsManager users; + private final Audit audit; + private final UserCredentialRepository passkeys; + private final PublicKeyCredentialUserEntityRepository passkeyHolders; + private final TotpService totp; + private final int attempts; + private final long windowMillis; + private final long lockMillis; + + public SignInGuard(LoginFailureRepository failures, JdbcUserDetailsManager users, + Audit audit, UserCredentialRepository passkeys, + PublicKeyCredentialUserEntityRepository passkeyHolders, TotpService totp, + @Value("${bastion.lockout.attempts:5}") int attempts, + @Value("${bastion.lockout.window-seconds:900}") int windowSeconds, + @Value("${bastion.lockout.seconds:900}") int lockSeconds) { + this.failures = failures; + this.users = users; + this.audit = audit; + this.passkeys = passkeys; + this.passkeyHolders = passkeyHolders; + this.totp = totp; + this.attempts = attempts; + this.windowMillis = windowSeconds * 1000L; + this.lockMillis = lockSeconds * 1000L; + } + + /// The name a sign-in was attempted under, the way it is stored. Variants + /// of case and spacing are one account, so they are one count. + static String attempted(HttpServer.Request request) { + String name; + try { + name = request.param("username"); + } catch (RuntimeException malformed) { + name = null; + } + return name == null ? "" : Accounts.asciiLower(name.trim()); + } + + /// Runs before the password is looked at, and refuses a locked account + /// without looking: a lock that still checked the password would tell the + /// guesser when they were right. + public SecurityFilter lockout() { + return (request, chain) -> { + if (PASSWORD_SIGN_IN.matches(request)) { + String username = attempted(request); + if (username.length() > 0 && locked(username)) { + audit.append(username, "SIGNIN_LOCKED", username, "password"); + return new HttpServer.Response(423, "application/json", + "{\"signedIn\":false,\"locked\":true}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"); + } + // A passkey account takes a password only on the way to a + // recovery code, which needs an authenticator app behind it. + if (username.length() > 0 && hasPasskey(username) && !totp.isEnabled(username)) { + audit.append(username, "SIGNIN_REFUSED", username, + "password: the account has a passkey"); + return json(403, "{\"signedIn\":false,\"stronger\":\"passkey\"}"); + } + } + return chain.doFilter(request); + }; + } + + /// Runs before the second-factor filter: a password accepted for an account + /// with a passkey is finished only by a recovery code (see the class + /// comment). The pending sign-in is left as it is, so the recovery code + /// can still follow it. + public SecurityFilter passkeyFirst() { + return (request, chain) -> { + if (SECOND_FACTOR.matches(request)) { + HttpSession session = request.getSession(false); + Object pending = session == null ? null + : session.getAttribute(SecondFactorAuthenticationFilter.PENDING); + Object name = pending instanceof Map ? ((Map) pending).get("name") : null; + String code; + try { + code = request.param("code"); + } catch (RuntimeException malformed) { + code = null; + } + if (name instanceof String && hasPasskey((String) name) + && !RecoveryCodeService.isCodeShaped(code)) { + String username = Accounts.asciiLower((String) name); + audit.append(username, "SIGNIN_REFUSED", username, + "totp: the account has a passkey"); + return json(403, "{\"signedIn\":false,\"stronger\":\"passkey\"," + + "\"recovery\":true}"); + } + } + return chain.doFilter(request); + }; + } + + /// Whether `username` has registered a passkey. + public boolean hasPasskey(String username) { + PublicKeyCredentialUserEntity holder = passkeyHolders.findByUsername(username); + return holder != null && !passkeys.findByUserId(holder.getId()).isEmpty(); + } + + /// Answers a completed sign-in -- a password with no second factor, a + /// password and a code, or a passkey -- and records it. `how` goes into + /// the record. + public AuthenticationSuccessHandler signedIn(final String how) { + return (request, authentication) -> { + String username = authentication.getName(); + cleared(username); + audit.append(username, "SIGNIN", username, how); + return json(200, "{\"signedIn\":true,\"authenticated\":true}"); + }; + } + + /// Answers a refused sign-in, and counts it against the account. + public AuthenticationFailureHandler refused(final String how) { + return (request, failure) -> { + String username = "password".equals(how) ? attempted(request) : ""; + boolean known = username.length() > 0 && users.userExists(username); + if (known) { + try { + failed(username); + } catch (IOException | RuntimeException race) { + // failed() already retries a lost first insert; anything + // still thrown is the database, and failing the answer + // would cost more than the one uncounted guess. + System.err.println("bastion: lockout count failed: " + race); + } + } + // A made-up name is recorded as one: the actor column holds only + // names of real accounts, so a search by actor is a customer's + // history and nothing else. + audit.append(known ? username : "unknown", "SIGNIN_FAILED", + username.length() > 0 ? username : "-", + how + ": " + failure.getClass().getSimpleName()); + return json(401, "{\"signedIn\":false,\"authenticated\":false}"); + }; + } + + /// The password asked for again inside a signed-in session (adding an + /// app, a device, revoking one), held to the same lockout as the sign-in + /// form: a locked account is refused before the password is looked at, + /// a wrong one counts, a right one wipes the count. A missing password is + /// a client that has not asked yet, not a guess, so it is not counted. + /// `refusal` is what (`code`, English) a wrong or missing one answers. + public void recheck(String username, String password, PasswordEncoder encoder, + String event, String code, String english) throws IOException { + if (locked(username)) { + audit.append(username, event, username, "account locked"); + throw Refused.with(423, "account-locked", + "Too many wrong passwords: this account is locked for a while"); + } + if (password == null || password.length() == 0) { + audit.append(username, event, username, "no password"); + throw Refused.with(403, code, english); + } + if (!encoder.matches(password, users.loadUserByUsername(username).getPassword())) { + failed(username); + audit.append(username, event, username, "password"); + throw Refused.with(403, code, english); + } + cleared(username); + } + + @Transactional(readOnly = true) + public boolean locked(String username) throws IOException { + LoginFailure row = failures.findById(username); + return row != null && row.getLockedUntil().toEpochMilli() > System.currentTimeMillis(); + } + + /// One more wrong password for `username`; locks the account on the last + /// one the window allows. + /// + /// Counted by the database, not read, incremented and written back: two + /// wrong passwords at once each read the same count, and one of them was + /// lost -- a guesser sending in parallel got more guesses than the window + /// allows. The row is made first (a duplicate-key loss to a concurrent + /// first failure is fine: the row exists either way), then the window + /// restart, the increment and the lock are each a single UPDATE on it. + public void failed(String username) throws IOException { + if (!exists(username)) { + try { + create(username); + } catch (IOException | RuntimeException race) { + if (!exists(username)) { + throw race; + } + } + } + countOne(username); + } + + @Transactional(readOnly = true) + private boolean exists(String username) throws IOException { + return failures.existsById(username); + } + + @Transactional(rollbackFor = Exception.class) + private void create(String username) throws IOException { + LoginFailure row = new LoginFailure(); + row.setUsername(username); + row.setFirstAt(Instant.now()); + failures.insert(row); + } + + @Transactional(rollbackFor = Exception.class) + private void countOne(String username) throws IOException { + long now = System.currentTimeMillis(); + failures.restartWindow(username, Instant.ofEpochMilli(now), Instant.ofEpochMilli(now - windowMillis)); + failures.increment(username); + if (failures.lockIfReached(username, attempts, Instant.ofEpochMilli(now), Instant.ofEpochMilli(now + lockMillis)) == 1) { + audit.append(username, "ACCOUNT_LOCKED", username, + attempts + " wrong passwords; " + lockMillis / 1000 + "s"); + } + } + + /// A completed sign-in wipes what was counted. + @Transactional(rollbackFor = Exception.class) + public void cleared(String username) throws IOException { + if (failures.existsById(username)) { + failures.deleteById(username); + } + } + + private static HttpServer.Response json(int status, String body) throws IOException { + return new HttpServer.Response(status, "application/json", body.getBytes("UTF-8")) + .header("Cache-Control", "no-store"); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/admin/AdminEndpoint.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/admin/AdminEndpoint.java new file mode 100644 index 00000000000..f99d2a11a6e --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/admin/AdminEndpoint.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.admin; + +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codenameone.examples.bastion.Caller; +import com.codenameone.examples.bastion.Roles; +import com.codenameone.examples.bastion.api.AdminApiServer; +import com.codenameone.examples.bastion.api.AdjustmentDto; +import com.codenameone.examples.bastion.api.ApprovalDto; +import com.codenameone.examples.bastion.api.ClosePeriodDto; +import com.codenameone.examples.bastion.api.DecisionDto; +import com.codenameone.examples.bastion.api.PeriodDto; +import com.codenameone.examples.bastion.api.ReversalRequestDto; +import com.codenameone.examples.bastion.api.VerificationDto; +import com.codenameone.examples.bastion.account.SignInGuard; +import com.codenameone.examples.bastion.books.Approvals; +import com.codenameone.examples.bastion.books.Books; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.ledger.Ledger; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// [com.codenameone.examples.bastion.api.AdminApi], served. +/// +/// `/api/admin/**` is already refused to anyone without ADMIN by the API +/// chain's rules. Each call asks again here: a rule is one line in another +/// file, and a route moved out from under it would otherwise be open to every +/// customer. +@Component +public class AdminEndpoint implements AdminApiServer { + private final Audit audit; + private final Ledger ledger; + private final DataSource db; + + private final Books books; + private final Approvals approvals; + private final SignInGuard guard; + private final PasswordEncoder encoder; + + public AdminEndpoint(Audit audit, Ledger ledger, DataSource db, Books books, + Approvals approvals, SignInGuard guard, PasswordEncoder encoder) { + this.books = books; + this.approvals = approvals; + this.guard = guard; + this.encoder = encoder; + this.audit = audit; + this.ledger = ledger; + this.db = db; + } + + @Override + public VerificationDto verifyAudit() throws Exception { + String admin = admin(); + List problems = audit.verify(); + // Who looked, and what they saw, is itself on the record. Appended + // after the walk so the check is of the chain as it was asked about. + audit.append(admin, "AUDIT_VERIFIED", "audit", problems.isEmpty() ? "intact" + : problems.size() + " problems"); + return new VerificationDto(problems.isEmpty(), problems); + } + + @Override + public VerificationDto verifyLedger() throws Exception { + String admin = admin(); + List rows = db.query("SELECT DISTINCT owner FROM ledger_account", new Object[0]); + List owners = new ArrayList(); + for (int iter = 0; rows != null && iter < rows.size(); iter++) { + Object row = rows.get(iter); + Object owner = row instanceof Map ? ((Map) row).get("owner") : null; + if (owner != null) { + owners.add(String.valueOf(owner)); + } + } + List problems = ledger.verify(owners); + audit.append(admin, "LEDGER_VERIFIED", "ledger", problems.isEmpty() ? "balanced" + : problems.size() + " problems"); + return new VerificationDto(problems.isEmpty(), problems); + } + + @Override + public PeriodDto closePeriod(ClosePeriodDto request) throws Exception { + String admin = stepUp(request == null ? null : request.password()); + return books.close(admin, request.through()); + } + + @Override + public ApprovalDto adjust(String idempotencyKey, AdjustmentDto request) throws Exception { + String admin = stepUp(request == null ? null : request.password()); + return approvals.adjust(admin, idempotencyKey, request.accountId(), request.amount(), + request.memo()); + } + + @Override + public ApprovalDto reverse(String id, String idempotencyKey, ReversalRequestDto request) + throws Exception { + String admin = stepUp(request == null ? null : request.password()); + return approvals.reverse(admin, idempotencyKey, id, request.memo()); + } + + @Override + public List approvals() throws Exception { + admin(); + return approvals.list(); + } + + @Override + public ApprovalDto approve(String id, DecisionDto decision) throws Exception { + String admin = stepUp(decision == null ? null : decision.password()); + return approvals.approve(admin, id); + } + + @Override + public ApprovalDto reject(String id, DecisionDto decision) throws Exception { + String admin = stepUp(decision == null ? null : decision.password()); + return approvals.reject(admin, id, decision.reason()); + } + + /// The administrator, having typed their password again: every change + /// to the books is a step-up, held to the sign-in lockout like any other + /// password guess. Checked -- and a refusal audited -- before the + /// change's own transaction, which would roll the record back with it. + private String stepUp(String password) throws Exception { + String admin = admin(); + guard.recheck(admin, password, encoder, "STAFF_STEP_UP_REFUSED", + "enter-password-confirm", "Enter your password to confirm"); + return admin; + } + + private static String admin() { + String name = Caller.name(); + if (!Caller.hasRole(Roles.ADMIN)) { + throw Refused.with(403, "administrators", "Administrators only"); + } + return name; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/audit/Audit.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/audit/Audit.java new file mode 100644 index 00000000000..ed7894269ec --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/audit/Audit.java @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.audit; + +import java.time.Instant; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.bastion.Caller; +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.api.ChainReportDto; +import com.codenameone.examples.bastion.domain.AuditEvent; +import com.codenameone.examples.bastion.domain.AuditHead; +import com.codenameone.examples.bastion.repository.AuditEventRepository; +import com.codenameone.examples.bastion.repository.AuditHeadRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The tamper-evident log of everything that matters: sign-ins, enrolments, +/// every movement of money, every admin action. +/// +/// An event is appended in the transaction of the change it records, so the +/// two commit or roll back together -- there is no transfer without its audit +/// row and no audit row for a transfer that did not happen. Each row's hash +/// covers its own fields and the hash of the row before it. +@Component +public class Audit { + /// The hash the chain starts from. + public static final String GENESIS = + "0000000000000000000000000000000000000000000000000000000000000000"; + + private final AuditEventRepository events; + private final AuditHeadRepository head; + + public Audit(AuditEventRepository events, AuditHeadRepository head) { + this.events = events; + this.head = head; + } + + /// Records `action` by the current caller, or by `actor` outside a request. + @Transactional(rollbackFor = Exception.class) + public AuditEvent append(String actor, String action, String subject, String detail) + throws IOException { + if (head.hold() != 1) { + throw new IllegalStateException("audit_head is missing; V3__audit.sql creates it"); + } + AuditHead tail = head.findById(AuditHeadRepository.ONLY); + AuditEvent event = new AuditEvent(); + event.setSeq(tail.getSeq() + 1); + event.setAt(Instant.now()); + event.setActor(clip(actor, 190)); + event.setAction(clip(action, 40)); + event.setSubject(clip(subject, 190)); + event.setDetail(clip(detail, 500)); + event.setAddress(clip(Caller.address(), 64)); + event.setPrevHash(tail.getHash()); + event.setHash(hash(event)); + events.insert(event); + head.advance(event.getSeq(), event.getHash()); + // The update above bypasses the session, whose copy of the head would + // otherwise still say the old seq: a second append in the same + // transaction -- an order and its trades -- would reuse it. + tail.setSeq(event.getSeq()); + tail.setHash(event.getHash()); + return event; + } + + /// Walks the whole chain and answers the sequence numbers that do not + /// hold: a row whose hash is not its own, a row that does not link to the + /// one before it, a missing number, or a head that disagrees with the + /// last row. Empty means intact. + /// + /// Page by page, each in its own read, so memory stays one page however + /// long the chain grows (a whole-table read held every row, and the ORM + /// session a copy of each). The head is read first and the walk stops at + /// it: rows appended while the walk runs are past the head it compares + /// against, and are checked on the next run. + public List verify() throws IOException { + return report().problems(); + } + + /// [#verify] with what an auditor needs to act on it: how many links + /// were checked, the head, and the FIRST link that does not hold (0 when + /// none). Everything after a broken link is suspect -- its hash was + /// computed from a predecessor that is no longer what was written -- so + /// the first break is where an investigation starts. + public ChainReportDto report() throws IOException { + List problems = new ArrayList(); + long firstBroken = 0; + long checked = 0; + AuditHead tail = tail(); + long end = tail == null ? Long.MAX_VALUE : tail.getSeq(); + String prev = GENESIS; + long expected = 1; + long after = 0; + boolean more = true; + while (more) { + List page = page(after); + more = !page.isEmpty(); + for (AuditEvent e : page) { + if (e.getSeq() > end) { + more = false; + break; + } + checked++; + int before = problems.size(); + if (e.getSeq() != expected) { + problems.add("missing #" + expected + " before #" + e.getSeq()); + } + if (!prev.equals(e.getPrevHash())) { + problems.add("#" + e.getSeq() + " does not link to the row before it"); + } + if (!hash(e).equals(e.getHash())) { + problems.add("#" + e.getSeq() + " was changed after it was written"); + } + if (firstBroken == 0 && problems.size() > before) { + firstBroken = e.getSeq() != expected ? expected : e.getSeq(); + } + prev = e.getHash(); + expected = e.getSeq() + 1; + after = e.getSeq(); + } + } + if (tail != null && (tail.getSeq() != expected - 1 || !tail.getHash().equals(prev))) { + problems.add("the head says #" + tail.getSeq() + " but the chain ends at #" + + (expected - 1)); + if (firstBroken == 0) { + firstBroken = expected; + } + } + return new ChainReportDto(problems.isEmpty(), checked, tail == null ? 0L : tail.getSeq(), + tail == null ? GENESIS : tail.getHash(), firstBroken, problems); + } + + /// Links after `after`, oldest first, at most `limit`. + @Transactional(readOnly = true) + public List after(long after, int limit) throws IOException { + List page = events.findTop500BySeqGreaterThanOrderBySeqAsc(after); + return page.size() <= limit ? page : new ArrayList(page.subList(0, limit)); + } + + /// The hash an event should carry, for exports that let an auditor + /// recompute the chain offline. + public static String expectedHash(AuditEvent e) throws IOException { + return hash(e); + } + + @Transactional(readOnly = true) + private AuditHead tail() throws IOException { + return head.findById(AuditHeadRepository.ONLY); + } + + @Transactional(readOnly = true) + private List page(long after) throws IOException { + return events.findTop500BySeqGreaterThanOrderBySeqAsc(after); + } + + static String hash(AuditEvent e) throws IOException { + // Length-prefixed fields: "ab"+"c" and "a"+"bc" must not hash alike. + StringBuilder b = new StringBuilder(); + field(b, e.getPrevHash()); + field(b, Long.toString(e.getSeq())); + field(b, Long.toString(e.getAt().toEpochMilli())); + field(b, e.getActor()); + field(b, e.getAction()); + field(b, e.getSubject()); + field(b, e.getDetail()); + field(b, e.getAddress()); + return Ids.sha256(b.toString()); + } + + private static void field(StringBuilder b, String value) { + String v = value == null ? "" : value; + b.append(v.length()).append(':').append(v).append(';'); + } + + private static String clip(String value, int max) { + if (value == null) { + return ""; + } + return value.length() <= max ? value : value.substring(0, max); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/Approvals.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/Approvals.java new file mode 100644 index 00000000000..0c37c49548d --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/Approvals.java @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.books; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.Refused; +import com.codenameone.examples.bastion.api.ApprovalDto; +import com.codenameone.examples.bastion.api.JournalEntryDto; +import com.codenameone.examples.bastion.api.JournalLegDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.JournalEntry; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Leg; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codenameone.examples.bastion.repository.LedgerAccountRepository; +import com.codenameone.examples.bastion.repository.ProfileRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +/// Maker-checker for the two things an administrator can do to money: a +/// manual adjustment of a customer account and the reversal of an entry. +/// +/// What will be posted is fixed when the request is made -- the account, +/// the amount, the entry -- and the request is valued in USD. At or below +/// `bastion.approval.threshold-usd-cents` it posts at once; above it, it waits +/// PENDING until a **different** administrator approves it. The approval +/// claims the row with a conditional update (`WHERE status = 'PENDING'`) +/// before it posts, so two approvals arriving together execute it once. +/// Every step is appended to the audit chain in the transaction that takes it. +@Component +public class Approvals { + public static final String ADJUSTMENT = "ADJUSTMENT"; + public static final String REVERSAL = "REVERSAL"; + public static final String PENDING = "PENDING"; + public static final String EXECUTED = "EXECUTED"; + public static final String REJECTED = "REJECTED"; + + private final DataSource db; + private final Ledger ledger; + private final Books books; + private final Audit audit; + private final LedgerAccountRepository accounts; + private final ProfileRepository profiles; + private final long threshold; + + public Approvals(DataSource db, Ledger ledger, Books books, Audit audit, + LedgerAccountRepository accounts, ProfileRepository profiles, + @Value("${bastion.approval.threshold-usd-cents:100000}") String threshold) { + this.db = db; + this.ledger = ledger; + this.books = books; + this.audit = audit; + this.accounts = accounts; + this.profiles = profiles; + this.threshold = Long.parseLong(threshold.trim()); + } + + public long threshold() { + return threshold; + } + + @Transactional(rollbackFor = Exception.class) + public ApprovalDto adjust(String maker, String idempotencyKey, String accountId, long amount, + String memo) throws IOException { + boolean known = holdMaker(maker); + String key = Ledger.clientKey(maker, idempotencyKey); + LedgerAccount account = accountId == null ? null : accounts.findById(accountId); + // Customer money only, and never an order's escrow: the bank's own + // accounts move by the entries that explain them. + if (account == null || !AccountKinds.isCustomer(account.getKind()) + || AccountKinds.HOLD.equals(account.getKind())) { + throw Refused.with(404, "no-such-account", "No such account"); + } + if (amount == 0) { + throw Refused.with(400, "amount-cannot-zero", "An amount cannot be zero"); + } + ApprovalDto earlier = byKey(key); + if (earlier != null) { + if (!ADJUSTMENT.equals(earlier.kind()) || !accountId.equals(earlier.accountId()) + || earlier.amount() != amount) { + throw Refused.with(422, "idempotency-key-reused", "That Idempotency-Key was already used for a different request"); + } + return earlier; + } + long usd = books.priced(account.getAsset(), amount < 0 ? -amount : amount); + return create(known, ADJUSTMENT, maker, accountId, "", account.getAsset(), amount, usd, + clip(memo), key); + } + + @Transactional(rollbackFor = Exception.class) + public ApprovalDto reverse(String maker, String idempotencyKey, String entryId, String memo) + throws IOException { + boolean known = holdMaker(maker); + String key = Ledger.clientKey(maker, idempotencyKey); + JournalEntryDto entry = books.entry(entryId); + if (Ledger.REVERSAL.equals(entry.kind()) || Ledger.CLOSING.equals(entry.kind())) { + throw Refused.with(409, "entry-cannot-reversed", "That entry cannot be reversed"); + } + if (entry.reversedBy().length() > 0) { + throw Refused.with(409, "entry-already-reversed", "That entry was already reversed"); + } + ApprovalDto earlier = byKey(key); + if (earlier != null) { + if (!REVERSAL.equals(earlier.kind()) || !entryId.equals(earlier.entryId())) { + throw Refused.with(422, "idempotency-key-reused", "That Idempotency-Key was already used for a different request"); + } + return earlier; + } + // Valued as everything it moves: the debits of every asset. + // One unpriced leg makes the whole request unpriced (UNPRICED). + long usd = 0; + String asset = ""; + for (JournalLegDto leg : entry.legs()) { + if (leg.debit() > 0) { + long v = books.priced(leg.asset(), leg.debit()); + usd = v == Books.UNPRICED || usd == Books.UNPRICED ? Books.UNPRICED : usd + v; + asset = asset.length() == 0 ? leg.asset() : asset; + } + } + return create(known, REVERSAL, maker, "", entryId, asset, 0L, usd, clip(memo), key); + } + + /// Not transactional on purpose: a maker approving their own request is + /// refused here, before the approval's transaction opens, so the + /// `APPROVAL_REFUSED` row commits in a transaction of its own instead of + /// being rolled back by the refusal that follows it. + public ApprovalDto approve(String checker, String id) throws IOException { + ApprovalDto a = get(id); + if (PENDING.equals(a.status()) && a.maker().equals(checker)) { + audit.append(checker, "APPROVAL_REFUSED", id, "maker cannot approve own request"); + throw Refused.with(403, "four-eyes-second-administrator", "A second administrator must approve this"); + } + return approveAsChecker(checker, id); + } + + @Transactional(rollbackFor = Exception.class) + ApprovalDto approveAsChecker(String checker, String id) throws IOException { + ApprovalDto a = get(id); + if (!PENDING.equals(a.status())) { + throw Refused.with(409, "approval-not-pending", "That request is no longer waiting for approval"); + } + if (a.maker().equals(checker)) { + // Only reachable when the maker changed between the two reads, + // which cannot happen; kept so this method stands on its own. + throw Refused.with(403, "four-eyes-second-administrator", "A second administrator must approve this"); + } + long now = System.currentTimeMillis(); + if (db.execute("UPDATE approval SET status = ?, checker = ?, decided_at = ? " + + "WHERE id = ? AND status = ?", new Object[] {EXECUTED, checker, + Long.valueOf(now), id, PENDING}) != 1) { + throw Refused.with(409, "approval-not-pending", "That request is no longer waiting for approval"); + } + String entry = execute(a); + db.execute("UPDATE approval SET result_entry = ? WHERE id = ?", new Object[] {entry, id}); + audit.append(checker, a.kind() + "_APPROVED", id, "maker " + a.maker() + ", entry " + + entry + ", " + a.usdCents() + " USD cents"); + return get(id); + } + + @Transactional(rollbackFor = Exception.class) + public ApprovalDto reject(String checker, String id, String reason) throws IOException { + ApprovalDto a = get(id); + if (db.execute("UPDATE approval SET status = ?, checker = ?, decided_at = ? " + + "WHERE id = ? AND status = ?", new Object[] {REJECTED, checker, + Long.valueOf(System.currentTimeMillis()), id, PENDING}) != 1) { + throw Refused.with(409, "approval-not-pending", "That request is no longer waiting for approval"); + } + audit.append(checker, a.kind() + "_REJECTED", id, clip(reason)); + return get(id); + } + + @Transactional(readOnly = true) + public List list() throws IOException { + List out = new ArrayList(); + for (Map r : books.rows("SELECT * FROM approval ORDER BY created_at DESC LIMIT 200", + new Object[0])) { + out.add(dto(r)); + } + return out; + } + + @Transactional(readOnly = true) + public ApprovalDto get(String id) throws IOException { + List rows = books.rows("SELECT * FROM approval WHERE id = ?", new Object[] {id}); + if (rows.isEmpty()) { + throw Refused.with(404, "not-found", "Not found"); + } + return dto(rows.get(0)); + } + + /// Test-only seam, null in production and package-private so only test + /// code in this package (`ApprovalsTestHooks`) can set it: runs between reading the maker's 24h + /// total and acting on it, so a test can hold that window open long + /// enough for concurrent requests to overlap. Without it the race is a few + /// microseconds wide and a missing lock still passes on PostgreSQL. + static final AtomicReference TEST_ONLY_AFTER_RECENT_TOTAL = + new AtomicReference(); + + /// Serializes one maker's requests: a no-op write of the maker's profile + /// row, like `ProfileRepository.hold` for password changes, locks it until + /// the transaction ends on SQLite, PostgreSQL and MySQL alike. Without it + /// N concurrent requests just under the threshold all read the same 24h + /// total and all post without a checker. Taken before the idempotency + /// lookup too, so two retries of one key cannot both insert. + /// Answers false when the maker has no profile, so nothing was locked; + /// [create] then fails closed and the request waits for a checker. + private boolean holdMaker(String maker) { + return maker != null && profiles.hold(maker) > 0; + } + + private ApprovalDto create(boolean known, String kind, String maker, String accountId, + String entryId, String asset, long amount, long usd, String memo, String key) + throws IOException { + String id = Ids.next(); + long now = System.currentTimeMillis(); + // Fail closed: a request that cannot be valued is not known to be + // small, so it waits for a second administrator. + boolean unpriced = usd == Books.UNPRICED; + if (unpriced) { + usd = 0L; + } + // Splitting one large change into many just under the threshold + // posts nothing alone: what this maker posted without a checker in + // the last 24 hours counts towards this request. + long recent = unpriced ? 0L : Books.number(Books.first(books.rows( + "SELECT COALESCE(SUM(usd_cents), 0) AS t FROM approval WHERE maker = ? " + + "AND status = ? AND checker = ? AND created_at > ?", new Object[] {maker, + EXECUTED, "", Long.valueOf(now - Books.DAY)}), "t")); + Runnable pause = TEST_ONLY_AFTER_RECENT_TOTAL.get(); + if (pause != null) { + pause.run(); + } + // Subtracted, not added: a value saturates at Long.MAX_VALUE. + boolean waits = !known || unpriced || usd > threshold - recent; + db.execute("INSERT INTO approval (id, kind, status, maker, checker, account_id, entry_id, " + + "asset, amount, usd_cents, memo, request_key, result_entry, created_at, " + + "decided_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + new Object[] {id, kind, waits ? PENDING : EXECUTED, maker, "", accountId, entryId, + asset, Long.valueOf(amount), Long.valueOf(usd), memo, key, "", + Long.valueOf(now), Long.valueOf(waits ? 0L : now)}); + ApprovalDto a = get(id); + if (waits) { + audit.append(maker, kind + "_REQUESTED", id, describe(a) + (!known ? ", maker has no profile" + : unpriced ? ", unpriced" + : recent > 0 ? ", " + recent + " USD cents posted without a checker in 24h" + : "") + ", waits for a second administrator"); + return a; + } + String entry = execute(a); + db.execute("UPDATE approval SET result_entry = ? WHERE id = ?", new Object[] {entry, id}); + audit.append(maker, kind + "_POSTED", id, describe(a) + ", within the " + + threshold + " USD cent threshold (" + recent + " earlier in 24h), entry " + entry); + return get(id); + } + + private String execute(ApprovalDto a) throws IOException { + if (REVERSAL.equals(a.kind())) { + return ledger.reverse("rev:" + a.id(), a.entryId(), a.maker(), a.memo()).getId(); + } + LedgerAccount adjust = ledger.system(a.asset(), AccountKinds.SYS_ADJUST); + JournalEntry entry = ledger.post("adj:" + a.id(), ADJUSTMENT, a.maker(), + a.memo().length() == 0 ? "Adjustment" : a.memo(), Arrays.asList( + new Leg(a.accountId(), a.amount()), new Leg(adjust.getId(), -a.amount()))); + return entry.getId(); + } + + private ApprovalDto byKey(String key) throws IOException { + List rows = books.rows("SELECT * FROM approval WHERE request_key = ?", + new Object[] {key}); + return rows.isEmpty() ? null : dto(rows.get(0)); + } + + private static String describe(ApprovalDto a) { + return REVERSAL.equals(a.kind()) ? "reverse " + a.entryId() + " (" + a.usdCents() + + " USD cents)" : a.amount() + " " + a.asset() + " to " + a.accountId() + " (" + + a.usdCents() + " USD cents)"; + } + + private static ApprovalDto dto(Map r) { + return new ApprovalDto(Books.str(r, "id"), Books.str(r, "kind"), Books.str(r, "status"), + Books.str(r, "maker"), Books.str(r, "checker"), Books.str(r, "account_id"), + Books.str(r, "entry_id"), Books.str(r, "asset"), Books.num(r, "amount"), + Books.num(r, "usd_cents"), Books.str(r, "memo"), Books.str(r, "result_entry"), + Books.num(r, "created_at"), Books.num(r, "decided_at")); + } + + private static String clip(String s) { + if (s == null) { + return ""; + } + return s.length() <= 120 ? s : s.substring(0, 120); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/AuditTrailEndpoint.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/AuditTrailEndpoint.java new file mode 100644 index 00000000000..e498ab42b21 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/AuditTrailEndpoint.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.books; + +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.Refused; +import com.codenameone.examples.bastion.api.AuditApiServer; +import com.codenameone.examples.bastion.api.AuditEventDto; +import com.codenameone.examples.bastion.api.ChainReportDto; +import com.codenameone.examples.bastion.api.EvidenceDto; +import com.codenameone.examples.bastion.api.JournalEntryDto; +import com.codenameone.examples.bastion.api.JournalLegDto; +import com.codenameone.examples.bastion.api.TrialBalanceDto; +import com.codenameone.examples.bastion.api.TrialBalanceLineDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.AuditEvent; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// [com.codenameone.examples.bastion.api.AuditApi], served: the chain, the +/// trail of one entity, the staff access log and exportable evidence. Staff +/// only; reading is recorded. +@Component +public class AuditTrailEndpoint implements AuditApiServer { + /// Journal entries read per page of an export. The export itself has no + /// cap: its digest goes on the chain as the whole journal, so it must be. + static final int EXPORT_PAGE = 200; + + private final Audit audit; + private final Books books; + + public AuditTrailEndpoint(Audit audit, Books books) { + this.audit = audit; + this.books = books; + } + + @Override + public ChainReportDto chain() throws Exception { + String who = Staff.name(); + ChainReportDto report = audit.report(); + audit.append(who, "AUDIT_VERIFIED", "audit", report.intact() ? "intact" + : "first broken link #" + report.firstBrokenSeq()); + return report; + } + + @Override + public List events(String after, String limit) throws Exception { + long a = Math.max(0L, BooksEndpoint.number(after, 0L)); + int n = BooksEndpoint.clamp(limit, 100, 500); + Staff.read(audit, "audit", "events after #" + a); + List out = new ArrayList(); + for (AuditEvent e : audit.after(a, n)) { + out.add(dto(e)); + } + return out; + } + + @Override + public List trail(String subject) throws Exception { + if (subject == null || subject.length() == 0 || subject.length() > 190) { + throw Refused.with(400, "name-entity", "Name the entity to trace"); + } + Staff.read(audit, subject, "audit trail"); + return query("SELECT * FROM audit_event WHERE subject = ? OR actor = ? " + + "ORDER BY seq DESC LIMIT 500", new Object[] {subject, subject}); + } + + @Override + public List accessLog(String limit) throws Exception { + int n = BooksEndpoint.clamp(limit, 100, 500); + Staff.read(audit, "access-log", "last " + n); + return query("SELECT * FROM audit_event WHERE action IN (?, ?, ?, ?, ?) " + + "ORDER BY seq DESC LIMIT " + n, new Object[] {"STAFF_READ", "EVIDENCE_EXPORTED", + "AUDIT_VERIFIED", "LEDGER_VERIFIED", "RECONCILED"}); + } + + @Override + public EvidenceDto evidence(String name, String format, String asOf) throws Exception { + boolean json = format != null && format.equalsIgnoreCase("json"); + if (format != null && format.length() > 0 && !json && !format.equalsIgnoreCase("csv")) { + throw Refused.with(400, "format-csv-json", "The format is csv or json"); + } + String who = Staff.name(); + long at = BooksEndpoint.to(asOf); + List> rows = new ArrayList>(); + if ("journal".equals(name)) { + long afterAt = -1L; + String afterId = ""; + while (true) { + List page = books.journalAfter(afterAt, afterId, at, EXPORT_PAGE); + for (JournalEntryDto e : page) { + afterAt = e.at(); + afterId = e.id(); + for (JournalLegDto leg : e.legs()) { + Map r = new LinkedHashMap(); + r.put("entry_id", e.id()); + r.put("posted_at", Staff.iso(e.at())); + r.put("kind", e.kind()); + r.put("initiator", e.initiator()); + r.put("memo", e.memo()); + r.put("reverses", e.reverses()); + r.put("account_id", leg.accountId()); + r.put("code", leg.code()); + r.put("account", leg.accountName()); + r.put("asset", leg.asset()); + r.put("debit", Long.valueOf(leg.debit())); + r.put("credit", Long.valueOf(leg.credit())); + rows.add(r); + } + } + if (page.size() < EXPORT_PAGE) { + break; + } + } + } else if ("trial-balance".equals(name)) { + TrialBalanceDto tb = books.trialBalance(at); + for (TrialBalanceLineDto l : tb.lines()) { + Map r = new LinkedHashMap(); + r.put("as_of", Staff.iso(at)); + r.put("code", l.code()); + r.put("account", l.name()); + r.put("class", l.accountClass()); + r.put("asset", l.asset()); + r.put("debit", Long.valueOf(l.debit())); + r.put("credit", Long.valueOf(l.credit())); + rows.add(r); + } + } else if ("audit-chain".equals(name)) { + long after = 0; + while (true) { + List page = audit.after(after, 500); + for (AuditEvent e : page) { + after = e.getSeq(); + if (e.getAt().toEpochMilli() > at) { + continue; + } + Map r = new LinkedHashMap(); + r.put("seq", Long.valueOf(e.getSeq())); + r.put("at", Staff.iso(e.getAt().toEpochMilli())); + r.put("actor", e.getActor()); + r.put("action", e.getAction()); + r.put("subject", e.getSubject()); + r.put("detail", e.getDetail()); + r.put("address", e.getAddress()); + r.put("prev_hash", e.getPrevHash()); + r.put("hash", e.getHash()); + r.put("hash_ok", Boolean.valueOf(Audit.expectedHash(e).equals(e.getHash()))); + rows.add(r); + } + if (page.size() < 500) { + break; + } + } + } else { + throw Refused.with(404, "not-found", "Not found"); + } + String content = json ? Json.write(rows) : csv(rows); + String digest = Ids.sha256(content); + // The digest of exactly what was handed out goes on the chain, so an + // export can later be shown to be the one the bank produced. + audit.append(who, "EVIDENCE_EXPORTED", name, (json ? "json" : "csv") + " as of " + at + + ", " + rows.size() + " rows, sha256 " + digest); + return new EvidenceDto(name, json ? "json" : "csv", json ? "application/json" + : "text/csv", System.currentTimeMillis(), who, digest, content); + } + + private List query(String sql, Object[] params) throws Exception { + List out = new ArrayList(); + for (Map r : books.rows(sql, params)) { + out.add(new AuditEventDto(Books.num(r, "seq"), Books.num(r, "at"), + Books.str(r, "actor"), Books.str(r, "action"), Books.str(r, "subject"), + Books.str(r, "detail"), Books.str(r, "address"), Books.str(r, "prev_hash"), + Books.str(r, "hash"))); + } + return out; + } + + static AuditEventDto dto(AuditEvent e) { + return new AuditEventDto(e.getSeq(), e.getAt().toEpochMilli(), e.getActor(), e.getAction(), + e.getSubject(), e.getDetail(), e.getAddress(), e.getPrevHash(), e.getHash()); + } + + /// RFC 4180: a field with a comma, quote or line break is quoted, quotes + /// doubled. A field starting with `=`, `+`, `-`, `@`, a tab or a carriage + /// return is prefixed with a quote so a spreadsheet does not run it as a + /// formula (CSV injection, OWASP's list); memos and names are customer + /// text. + public static String csv(List> rows) { + StringBuilder b = new StringBuilder(); + if (rows.isEmpty()) { + return ""; + } + boolean first = true; + for (String key : rows.get(0).keySet()) { + b.append(first ? "" : ",").append(key); + first = false; + } + b.append("\r\n"); + for (Map r : rows) { + first = true; + for (Object v : r.values()) { + b.append(first ? "" : ","); + first = false; + String s = v == null ? "" : String.valueOf(v); + if (!(v instanceof Number) && s.length() > 0 + && "=+-@\t\r".indexOf(s.charAt(0)) >= 0) { + s = "'" + s; + } + if (s.indexOf(',') >= 0 || s.indexOf('"') >= 0 || s.indexOf('\n') >= 0 + || s.indexOf('\r') >= 0) { + StringBuilder q = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + q.append(c == '"' ? "\"\"" : String.valueOf(c)); + } + s = q.append('"').toString(); + } + b.append(s); + } + b.append("\r\n"); + } + return b.toString(); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/Books.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/Books.java new file mode 100644 index 00000000000..b700defdc62 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/Books.java @@ -0,0 +1,771 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.books; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.bastion.Refused; +import com.codenameone.examples.bastion.api.AssetTotalDto; +import com.codenameone.examples.bastion.api.BalanceSheetDto; +import com.codenameone.examples.bastion.api.BreakDto; +import com.codenameone.examples.bastion.api.ControlDto; +import com.codenameone.examples.bastion.api.GeneralLedgerDto; +import com.codenameone.examples.bastion.api.IncomeStatementDto; +import com.codenameone.examples.bastion.api.JournalEntryDto; +import com.codenameone.examples.bastion.api.JournalLegDto; +import com.codenameone.examples.bastion.api.LedgerLineDto; +import com.codenameone.examples.bastion.api.PeriodDto; +import com.codenameone.examples.bastion.api.ReconciliationDto; +import com.codenameone.examples.bastion.api.StatementLineDto; +import com.codenameone.examples.bastion.api.StatementTotalDto; +import com.codenameone.examples.bastion.api.TAccountDto; +import com.codenameone.examples.bastion.api.TrialBalanceDto; +import com.codenameone.examples.bastion.api.TrialBalanceLineDto; +import com.codenameone.examples.bastion.api.VolumeDayDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.JournalEntry; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Leg; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codenameone.examples.bastion.money.Money; +import com.codenameone.examples.bastion.repository.JournalEntryRepository; +import com.codenameone.examples.bastion.repository.LedgerAccountRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/// The accounting reports over the ledger, and period close. +/// +/// Everything here is derived from the postings at read time -- the journal, +/// the general ledger, trial balance, balance sheet and income statement -- +/// so no report can disagree with the ledger it reports on. The SQL is plain +/// SUM/GROUP BY that SQLite, PostgreSQL and MySQL all read alike; anything +/// calendar-shaped (days) is bucketed here in Java, because the three spell +/// date arithmetic three ways. +/// +/// Statements are per asset: a ledger in five currencies balances five times, +/// and nothing converts one into another inside the books. The USD figures +/// beside each line are a valuation at the current index price, for reading, +/// never posted. +@Component +public class Books { + public static final long DAY = 86400000L; + + private final DataSource db; + private final Ledger ledger; + private final Money money; + private final LedgerAccountRepository accounts; + private final JournalEntryRepository entries; + private final Audit audit; + + public Books(DataSource db, Ledger ledger, Money money, LedgerAccountRepository accounts, + JournalEntryRepository entries, Audit audit) { + this.audit = audit; + this.db = db; + this.ledger = ledger; + this.money = money; + this.accounts = accounts; + this.entries = entries; + } + + // ---------------------------------------------------------------- journal + + @Transactional(readOnly = true) + public List journal(long from, long to, int limit) throws IOException { + List heads = rows("SELECT id, created_at, kind, initiator, memo FROM journal_entry " + + "WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC, id DESC " + + "LIMIT " + limit, new Object[] {Long.valueOf(from), Long.valueOf(to)}); + return withLegs(heads); + } + + /// Up to `limit` entries posted at or before `to`, oldest first, after + /// the entry at (`afterAt`, `afterId`) -- a keyset page, so an export can + /// walk the whole journal; start with `-1` and `""`. + @Transactional(readOnly = true) + public List journalAfter(long afterAt, String afterId, long to, int limit) + throws IOException { + List heads = rows("SELECT id, created_at, kind, initiator, memo FROM journal_entry " + + "WHERE created_at <= ? AND (created_at > ? OR (created_at = ? AND id > ?)) " + + "ORDER BY created_at ASC, id ASC LIMIT " + limit, new Object[] { + Long.valueOf(to), Long.valueOf(afterAt), Long.valueOf(afterAt), afterId}); + return withLegs(heads); + } + + @Transactional(readOnly = true) + public JournalEntryDto entry(String id) throws IOException { + List heads = rows("SELECT id, created_at, kind, initiator, memo FROM journal_entry " + + "WHERE id = ?", new Object[] {id}); + if (heads.isEmpty()) { + throw Refused.with(404, "no-such-entry", "No such journal entry"); + } + return withLegs(heads).get(0); + } + + private List withLegs(List heads) throws IOException { + List out = new ArrayList(); + if (heads.isEmpty()) { + return out; + } + StringBuilder in = new StringBuilder(); + Object[] ids = new Object[heads.size()]; + for (int i = 0; i < heads.size(); i++) { + in.append(i == 0 ? "?" : ", ?"); + ids[i] = str(heads.get(i), "id"); + } + Map> legs = new LinkedHashMap>(); + for (Map r : rows("SELECT p.entry_id AS entry_id, p.account_id AS account_id, " + + "p.amount AS amount, a.kind AS kind, a.asset AS asset, a.owner AS owner, " + + "a.name AS name FROM posting p JOIN ledger_account a ON a.id = p.account_id " + + "WHERE p.entry_id IN (" + in + ") ORDER BY p.amount ASC, p.id ASC", ids)) { + String entry = str(r, "entry_id"); + List list = legs.get(entry); + if (list == null) { + list = new ArrayList(); + legs.put(entry, list); + } + long amount = num(r, "amount"); + String kind = str(r, "kind"); + list.add(new JournalLegDto(str(r, "account_id"), Chart.code(kind), accountName(r), + str(r, "owner"), str(r, "asset"), Chart.debit(amount), Chart.credit(amount))); + } + Map reverses = new LinkedHashMap(); + Map reversedBy = new LinkedHashMap(); + for (Map r : rows("SELECT entry_id, reversed_by FROM reversal WHERE entry_id IN (" + in + + ") OR reversed_by IN (" + in + ")", concat(ids, ids))) { + reverses.put(str(r, "reversed_by"), str(r, "entry_id")); + reversedBy.put(str(r, "entry_id"), str(r, "reversed_by")); + } + for (Map h : heads) { + String id = str(h, "id"); + List list = legs.get(id); + out.add(new JournalEntryDto(id, num(h, "created_at"), str(h, "kind"), + str(h, "initiator"), str(h, "memo"), orEmpty(reverses.get(id)), + orEmpty(reversedBy.get(id)), + list == null ? new ArrayList() : list)); + } + return out; + } + + // ------------------------------------------------------- ledgers, T-account + + /// The most postings a ledger or T-account lists; the totals and the + /// balances are summed in SQL over the whole window regardless. + public static final int LEDGER_LINES = 2000; + + private static final String LINE_COLUMNS = "SELECT p.entry_id AS entry_id, " + + "p.account_id AS account_id, p.amount AS amount, p.created_at AS created_at, " + + "a.kind AS kind, a.asset AS asset, a.owner AS owner, e.kind AS ekind, " + + "e.memo AS memo FROM posting p JOIN ledger_account a ON a.id = p.account_id " + + "JOIN journal_entry e ON e.id = p.entry_id "; + + /// Every posting under chart `code` in `asset` between `from` and `to`, + /// with the running balance on the code's normal side, starting from the + /// balance brought forward at `from`. Lists the latest [#LEDGER_LINES]. + @Transactional(readOnly = true) + public GeneralLedgerDto generalLedger(String code, String asset, long from, long to) + throws IOException { + String kind = Chart.kindOf(code); + if (kind == null) { + throw Refused.with(404, "no-such-account", "No such account"); + } + ledger.asset(asset); + Window w = window("FROM posting p JOIN ledger_account a ON a.id = p.account_id " + + "WHERE a.kind = ? AND a.asset = ?", new Object[] {kind, asset}, from, to, kind); + return new GeneralLedgerDto(code, asset, from, to, Chart.normal(kind, w.opening), + w.lines, w.debits, w.credits, Chart.normal(kind, w.opening - w.debits + w.credits), + w.truncated); + } + + @Transactional(readOnly = true) + public TAccountDto tAccount(String accountId, long from, long to) throws IOException { + LedgerAccount account = accountId == null ? null : accounts.findById(accountId); + if (account == null) { + throw Refused.with(404, "no-such-account", "No such account"); + } + String kind = account.getKind(); + Window w = window("FROM posting p JOIN ledger_account a ON a.id = p.account_id " + + "WHERE p.account_id = ?", new Object[] {accountId}, from, to, kind); + List debits = new ArrayList(); + List credits = new ArrayList(); + for (LedgerLineDto line : w.lines) { + if (line.debit() > 0) { + debits.add(line); + } else { + credits.add(line); + } + } + String cls = Chart.classOf(kind); + return new TAccountDto(accountId, Chart.code(kind), Chart.name(kind) + " - " + + account.getName(), cls, Chart.normalOf(cls), account.getAsset(), from, to, + Chart.normal(kind, w.opening), debits, credits, w.debits, w.credits, + Chart.normal(kind, w.opening - w.debits + w.credits), w.truncated); + } + + /// The postings matched by `where` (over `p` and `a`) in a window: the + /// net brought forward, the window's debit and credit totals -- summed in + /// SQL, so they are true however many postings there are -- and the + /// latest [#LEDGER_LINES] as lines, oldest first, whose running balance + /// is counted back from the true closing balance. + private Window window(String where, Object[] params, long from, long to, String kind) + throws IOException { + Window w = new Window(); + w.opening = number(first(rows("SELECT COALESCE(SUM(p.amount), 0) AS t " + where + + " AND p.created_at < ?", concat(params, new Object[] {Long.valueOf(from)})), + "t")); + Object[] range = concat(params, new Object[] {Long.valueOf(from), Long.valueOf(to)}); + Map sums = first(rows("SELECT COALESCE(SUM(CASE WHEN p.amount < 0 THEN -p.amount " + + "ELSE 0 END), 0) AS dr, COALESCE(SUM(CASE WHEN p.amount > 0 THEN p.amount " + + "ELSE 0 END), 0) AS cr, COUNT(*) AS n " + where + + " AND p.created_at >= ? AND p.created_at <= ?", range)); + w.debits = number(sums == null ? null : sums.get("dr")); + w.credits = number(sums == null ? null : sums.get("cr")); + w.truncated = number(sums == null ? null : sums.get("n")) > LEDGER_LINES; + String filter = where.substring(where.indexOf("WHERE")); + List newest = rows(LINE_COLUMNS + filter + " AND p.created_at >= ? " + + "AND p.created_at <= ? ORDER BY p.created_at DESC, p.id DESC LIMIT " + + LEDGER_LINES, range); + List ascending = new ArrayList(); + long listed = 0; + for (int i = newest.size() - 1; i >= 0; i--) { + ascending.add(newest.get(i)); + listed += num(newest.get(i), "amount"); + } + // A posting's amount is signed credit-positive (Chart.debit). + long closingNet = w.opening - w.debits + w.credits; + w.lines = lines(ascending, kind, closingNet - listed); + return w; + } + + private static final class Window { + long opening; + long debits; + long credits; + boolean truncated; + List lines; + } + + private static List lines(List rows, String kind, long openingNet) { + List out = new ArrayList(); + long net = openingNet; + for (Map r : rows) { + long amount = num(r, "amount"); + net += amount; + out.add(new LedgerLineDto(str(r, "entry_id"), num(r, "created_at"), str(r, "ekind"), + str(r, "memo"), str(r, "account_id"), str(r, "owner"), Chart.code(kind), + str(r, "asset"), Chart.debit(amount), Chart.credit(amount), + Chart.normal(kind, net))); + } + return out; + } + + // ------------------------------------------------------------ statements + + /// Net balance per chart code and asset as of `asOf`, in the debit or + /// credit column. Customer accounts appear as their control accounts -- + /// one line for all customers' checking -- as a general-ledger trial + /// balance does. + @Transactional(readOnly = true) + public TrialBalanceDto trialBalance(long asOf) throws IOException { + Map totals = new TreeMap(); + List lines = new ArrayList(); + for (Map r : balances(asOf)) { + String kind = str(r, "kind"); + String asset = str(r, "asset"); + long net = num(r, "net"); + long[] t = totals.get(asset); + if (t == null) { + t = new long[2]; + totals.put(asset, t); + } + t[0] += Chart.debit(net); + t[1] += Chart.credit(net); + lines.add(new TrialBalanceLineDto(Chart.code(kind), Chart.name(kind), + Chart.classOf(kind), asset, Chart.debit(net), Chart.credit(net))); + } + sortLines(lines); + List sums = new ArrayList(); + boolean balanced = true; + for (Map.Entry t : totals.entrySet()) { + boolean ok = t.getValue()[0] == t.getValue()[1]; + balanced &= ok; + sums.add(new AssetTotalDto(t.getKey(), t.getValue()[0], t.getValue()[1], ok)); + } + return new TrialBalanceDto(asOf, lines, sums, balanced); + } + + /// Assets = liabilities + equity in every asset, equity including the + /// period's earnings not yet closed into retained earnings. + @Transactional(readOnly = true) + public BalanceSheetDto balanceSheet(long asOf) throws IOException { + List lines = new ArrayList(); + Map perAsset = new TreeMap(); + long totalUsd = 0; + for (Map r : balances(asOf)) { + String kind = str(r, "kind"); + String asset = str(r, "asset"); + long net = num(r, "net"); + long[] t = perAsset.get(asset); + if (t == null) { + // assets, liabilities, equity, current earnings + t = new long[4]; + perAsset.put(asset, t); + } + String cls = Chart.classOf(kind); + long amount = Chart.normal(kind, net); + if (Chart.REVENUE.equals(cls)) { + t[3] += amount; + continue; + } + if (Chart.EXPENSE.equals(cls)) { + t[3] -= amount; + continue; + } + String section = Chart.ASSET.equals(cls) ? "ASSETS" + : Chart.LIABILITY.equals(cls) ? "LIABILITIES" : "EQUITY"; + t[Chart.ASSET.equals(cls) ? 0 : Chart.LIABILITY.equals(cls) ? 1 : 2] += amount; + long usd = usd(asset, amount); + if (Chart.ASSET.equals(cls)) { + totalUsd += usd; + } + lines.add(new StatementLineDto(section, Chart.code(kind), Chart.name(kind), asset, + amount, usd)); + } + List totals = new ArrayList(); + boolean balanced = true; + for (Map.Entry e : perAsset.entrySet()) { + String asset = e.getKey(); + long[] t = e.getValue(); + if (t[3] != 0) { + lines.add(new StatementLineDto("EQUITY", "3900", "Current period earnings", + asset, t[3], usd(asset, t[3]))); + } + totals.add(new StatementTotalDto(asset, "ASSETS", t[0], usd(asset, t[0]))); + totals.add(new StatementTotalDto(asset, "LIABILITIES", t[1], usd(asset, t[1]))); + totals.add(new StatementTotalDto(asset, "EQUITY", t[2] + t[3], + usd(asset, t[2] + t[3]))); + totals.add(new StatementTotalDto(asset, "CURRENT_EARNINGS", t[3], usd(asset, t[3]))); + balanced &= t[0] == t[1] + t[2] + t[3]; + } + sortStatement(lines); + return new BalanceSheetDto(asOf, lines, totals, totalUsd, balanced); + } + + /// Revenue and expenses between `from` and `to`, closing entries left + /// out: they move the period's result to retained earnings, and counting + /// them would show every closed period as having earned nothing. + @Transactional(readOnly = true) + public IncomeStatementDto incomeStatement(long from, long to) throws IOException { + List lines = new ArrayList(); + Map perAsset = new TreeMap(); + long netUsd = 0; + for (Map r : rows("SELECT a.kind AS kind, a.asset AS asset, SUM(p.amount) AS net " + + "FROM posting p JOIN ledger_account a ON a.id = p.account_id " + + "JOIN journal_entry e ON e.id = p.entry_id WHERE p.created_at >= ? " + + "AND p.created_at <= ? AND e.kind <> ? GROUP BY a.kind, a.asset", + new Object[] {Long.valueOf(from), Long.valueOf(to), Ledger.CLOSING})) { + String kind = str(r, "kind"); + if (!Chart.isNominal(kind)) { + continue; + } + String asset = str(r, "asset"); + long amount = Chart.normal(kind, num(r, "net")); + boolean revenue = Chart.REVENUE.equals(Chart.classOf(kind)); + long[] t = perAsset.get(asset); + if (t == null) { + t = new long[2]; + perAsset.put(asset, t); + } + t[revenue ? 0 : 1] += amount; + lines.add(new StatementLineDto(revenue ? "REVENUE" : "EXPENSES", Chart.code(kind), + Chart.name(kind), asset, amount, usd(asset, amount))); + } + List totals = new ArrayList(); + for (Map.Entry e : perAsset.entrySet()) { + String asset = e.getKey(); + long[] t = e.getValue(); + totals.add(new StatementTotalDto(asset, "REVENUE", t[0], usd(asset, t[0]))); + totals.add(new StatementTotalDto(asset, "EXPENSES", t[1], usd(asset, t[1]))); + long net = t[0] - t[1]; + long usd = usd(asset, net); + netUsd += usd; + totals.add(new StatementTotalDto(asset, "NET_INCOME", net, usd)); + } + sortStatement(lines); + return new IncomeStatementDto(from, to, lines, totals, netUsd); + } + + private List balances(long asOf) throws IOException { + return rows("SELECT a.kind AS kind, a.asset AS asset, SUM(p.amount) AS net " + + "FROM posting p JOIN ledger_account a ON a.id = p.account_id " + + "WHERE p.created_at <= ? GROUP BY a.kind, a.asset", + new Object[] {Long.valueOf(asOf)}); + } + + // -------------------------------------------------------- reconciliation + + /// Reconciles the books three ways and reports every break: + /// + /// - each account's kept balance against the sum of its postings; + /// - each customer control account (chart 2000-2200) as the general + /// ledger has it, against the sum of the individual customer accounts' + /// kept balances -- the sub-ledger; + /// - each entry's debits against its credits, per asset. + @Transactional(readOnly = true) + public ReconciliationDto reconcile() throws IOException { + List breaks = new ArrayList(); + Map controls = new TreeMap(); + for (Map r : rows("SELECT a.id AS id, a.kind AS kind, a.asset AS asset, " + + "a.balance AS balance, COALESCE(SUM(p.amount), 0) AS total " + + "FROM ledger_account a LEFT JOIN posting p ON p.account_id = a.id " + + "GROUP BY a.id, a.kind, a.asset, a.balance", new Object[0])) { + String kind = str(r, "kind"); + String asset = str(r, "asset"); + long kept = num(r, "balance"); + long posted = num(r, "total"); + if (kept != posted) { + breaks.add(new BreakDto("CACHED_BALANCE", asset, str(r, "id"), posted, kept, + kept - posted)); + } + if (AccountKinds.isCustomer(kind)) { + String key = asset + "|" + Chart.code(kind); + long[] c = controls.get(key); + if (c == null) { + c = new long[2]; + controls.put(key, c); + } + c[0] += posted; + c[1] += kept; + } + } + List out = new ArrayList(); + for (Map.Entry c : controls.entrySet()) { + String key = c.getKey(); + int bar = key.indexOf('|'); + String asset = key.substring(0, bar); + String code = key.substring(bar + 1); + long control = c.getValue()[0]; + long sub = c.getValue()[1]; + out.add(new ControlDto(asset, code, Chart.name(Chart.kindOf(code)), control, sub, + sub - control)); + if (control != sub) { + breaks.add(new BreakDto("CONTROL", asset, code, control, sub, sub - control)); + } + } + for (Map r : rows("SELECT entry_id, asset, SUM(amount) AS total FROM posting " + + "GROUP BY entry_id, asset HAVING SUM(amount) <> 0", new Object[0])) { + long total = num(r, "total"); + breaks.add(new BreakDto("UNBALANCED_ENTRY", str(r, "asset"), str(r, "entry_id"), 0L, + total, total)); + } + return new ReconciliationDto(System.currentTimeMillis(), breaks.isEmpty(), out, breaks); + } + + // ---------------------------------------------------------------- volume + + @Transactional(readOnly = true) + public List volume(int days) throws IOException { + long today = day(System.currentTimeMillis()); + long start = today - (days - 1) * DAY; + Map buckets = new TreeMap(); + Map lastEntry = new LinkedHashMap(); + Map> perDay = new TreeMap>(); + // An order hold and its release move a customer's own money into and + // out of escrow: no value changes hands. The market maker requotes + // every few seconds, so counting them buried every real day of + // activity under the last one; only entries between parties count. + for (Map r : rows("SELECT p.entry_id AS entry_id, p.asset AS asset, p.amount AS amount, " + + "p.created_at AS created_at FROM posting p " + + "JOIN journal_entry e ON e.id = p.entry_id " + + "WHERE p.created_at >= ? AND p.amount > 0 " + + "AND e.kind <> 'ORDER_HOLD' AND e.kind <> 'ORDER_RELEASE' ORDER BY p.entry_id", + new Object[] {Long.valueOf(start)})) { + long d = day(num(r, "created_at")); + String asset = str(r, "asset"); + String key = zeroPad(d) + "|" + asset; + long[] b = buckets.get(key); + if (b == null) { + b = new long[2]; + buckets.put(key, b); + } + String entry = str(r, "entry_id"); + Set seen = perDay.get(Long.valueOf(d)); + if (seen == null) { + seen = new HashSet(); + perDay.put(Long.valueOf(d), seen); + } + seen.add(entry); + if (!entry.equals(lastEntry.get(key))) { + b[0]++; + lastEntry.put(key, entry); + } + b[1] += num(r, "amount"); + } + List out = new ArrayList(); + for (Map.Entry b : buckets.entrySet()) { + String key = b.getKey(); + int bar = key.indexOf('|'); + Long d = Long.valueOf(key.substring(0, bar)); + out.add(new VolumeDayDto(d.longValue(), key.substring(bar + 1), + (int) b.getValue()[0], perDay.get(d).size(), b.getValue()[1])); + } + return out; + } + + // ---------------------------------------------------------- period close + + @Transactional(readOnly = true) + public List periods() throws IOException { + List out = new ArrayList(); + for (Map r : rows("SELECT seq, closed_through, closed_by, closed_at, entries " + + "FROM period_close ORDER BY seq DESC", new Object[0])) { + out.add(period(r)); + } + return out; + } + + /// Closes the books through `through`: for each asset, one closing entry + /// that brings every revenue and expense account to zero against + /// retained earnings, dated `through`; then the period row, after which + /// nothing may be posted at or before `through`. The row's sequence is + /// its key, so two closes racing cannot both commit. + /// + /// Audited in the same transaction, so a close and its record commit + /// together. + @Transactional(rollbackFor = Exception.class) + public PeriodDto close(String admin, long through) throws IOException { + long last = ledger.closedThrough(); + if (through <= last || through >= System.currentTimeMillis()) { + throw Refused.with(400, "period-end-past-last-close", "A period must end in the past, after the last one closed"); + } + long seq = number(first(rows("SELECT COALESCE(MAX(seq), 0) AS s FROM period_close", + new Object[0]), "s")) + 1; + Map> perAsset = new TreeMap>(); + Map retained = new TreeMap(); + for (Map r : rows("SELECT a.id AS id, a.kind AS kind, a.asset AS asset, " + + "SUM(p.amount) AS net FROM posting p JOIN ledger_account a " + + "ON a.id = p.account_id WHERE p.created_at <= ? GROUP BY a.id, a.kind, a.asset", + new Object[] {Long.valueOf(through)})) { + String kind = str(r, "kind"); + long net = num(r, "net"); + if (!Chart.isNominal(kind) || net == 0) { + continue; + } + String asset = str(r, "asset"); + List legs = perAsset.get(asset); + if (legs == null) { + legs = new ArrayList(); + perAsset.put(asset, legs); + } + legs.add(new Leg(str(r, "id"), -net)); + Long sum = retained.get(asset); + retained.put(asset, Long.valueOf((sum == null ? 0L : sum.longValue()) + net)); + } + List posted = new ArrayList(); + for (Map.Entry> e : perAsset.entrySet()) { + String asset = e.getKey(); + List legs = e.getValue(); + long toRetained = retained.get(asset).longValue(); + if (toRetained != 0) { + LedgerAccount re = ledger.system(asset, AccountKinds.SYS_RETAINED); + legs.add(new Leg(re.getId(), toRetained)); + } + if (legs.size() < 2) { + continue; + } + JournalEntry entry = ledger.postClosing("period-close:" + seq + ":" + asset, admin, + "Closing entry, period " + seq + " (" + asset + ")", legs, through); + posted.add(entry.getId()); + } + StringBuilder list = new StringBuilder(); + for (String id : posted) { + if (list.length() + id.length() + 1 > 500) { + break; + } + list.append(list.length() == 0 ? "" : ",").append(id); + } + long now = System.currentTimeMillis(); + db.execute("INSERT INTO period_close (seq, closed_through, closed_by, closed_at, entries) " + + "VALUES (?, ?, ?, ?, ?)", new Object[] {Long.valueOf(seq), Long.valueOf(through), + admin, Long.valueOf(now), list.toString()}); + audit.append(admin, "PERIOD_CLOSED", "period:" + seq, "through " + through + ", " + + posted.size() + " closing entries " + list); + return new PeriodDto(seq, through, admin, now, posted); + } + + private static PeriodDto period(Map r) { + List ids = new ArrayList(); + String list = str(r, "entries"); + int at = 0; + while (list.length() > 0 && at <= list.length()) { + int comma = list.indexOf(',', at); + int end = comma < 0 ? list.length() : comma; + if (end > at) { + ids.add(list.substring(at, end)); + } + at = end + 1; + } + return new PeriodDto(num(r, "seq"), num(r, "closed_through"), str(r, "closed_by"), + num(r, "closed_at"), ids); + } + + // --------------------------------------------------------------- helpers + + /// What [#priced] answers for an asset with no USD price. + public static final long UNPRICED = -1L; + + /// `amount` (not negative) of `asset` in US cents at the index price, or + /// [#UNPRICED] when it has none. For decisions -- maker-checker -- where + /// [#usd]'s zero for an unpriced asset would read as "small". + public long priced(String asset, long amount) throws IOException { + if (amount == 0) { + return 0L; + } + // Asked first: a refusal from usdValue would doom the transaction. + return money.hasUsdPrice(asset) ? money.usdValue(asset, amount) : UNPRICED; + } + + /// `amount` of `asset` in US cents at the index price, keeping its sign. + /// For display only: an unpriced asset shows as zero. + public long usd(String asset, long amount) throws IOException { + if (amount == 0) { + return 0L; + } + // An asset with no market has no USD value to show; the line in its + // own units is still exact. Asked, not caught: a refusal from + // usdValue would mark the reading transaction rollback-only. + if (!money.hasUsdPrice(asset)) { + return 0L; + } + long v = money.usdValue(asset, amount < 0 ? -amount : amount); + return amount < 0 ? -v : v; + } + + /// The UTC midnight `ms` falls on. + public static long day(long ms) { + long d = ms / DAY; + if (ms < 0 && d * DAY != ms) { + d--; + } + return d * DAY; + } + + private static String zeroPad(long v) { + String s = Long.toString(v); + StringBuilder b = new StringBuilder(); + for (int i = s.length(); i < 20; i++) { + b.append('0'); + } + return b.append(s).toString(); + } + + private static String accountName(Map r) { + String owner = str(r, "owner"); + String kind = str(r, "kind"); + return AccountKinds.BANK.equals(owner) ? Chart.name(kind) + : Chart.name(kind) + " - " + owner; + } + + private static void sortLines(List lines) { + Collections.sort(lines, (a, b) -> { + int c = a.asset().compareTo(b.asset()); + return c != 0 ? c : a.code().compareTo(b.code()); + }); + } + + private static void sortStatement(List lines) { + Collections.sort(lines, (a, b) -> { + int c = a.asset().compareTo(b.asset()); + return c != 0 ? c : a.code().compareTo(b.code()); + }); + } + + private static Object[] concat(Object[] a, Object[] b) { + Object[] out = new Object[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } + + public List rows(String sql, Object[] params) throws IOException { + List raw = db.query(sql, params); + List out = new ArrayList(); + for (int i = 0; raw != null && i < raw.size(); i++) { + Object o = raw.get(i); + if (o instanceof Map) { + out.add((Map) o); + } + } + return out; + } + + private static Map first(List rows) { + return rows.isEmpty() ? new LinkedHashMap() : rows.get(0); + } + + static Object first(List rows, String key) { + return first(rows).get(key); + } + + public static String str(Map r, String key) { + Object v = value(r, key); + return v == null ? "" : String.valueOf(v); + } + + public static long num(Map r, String key) { + return number(value(r, key)); + } + + /// A column by name whatever case the driver gave it. + private static Object value(Map r, String key) { + Object v = r.get(key); + if (v == null && !r.containsKey(key)) { + for (Object k : r.keySet()) { + if (k instanceof String && ((String) k).equalsIgnoreCase(key)) { + return r.get(k); + } + } + } + return v; + } + + static long number(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + if (value == null) { + return 0L; + } + String s = String.valueOf(value).trim(); + int dot = s.indexOf('.'); + return s.length() == 0 ? 0L : Long.parseLong(dot < 0 ? s : s.substring(0, dot)); + } + + private static String orEmpty(String s) { + return s == null ? "" : s; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/BooksEndpoint.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/BooksEndpoint.java new file mode 100644 index 00000000000..c9acf646ecd --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/BooksEndpoint.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.books; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.bastion.Refused; +import com.codenameone.examples.bastion.api.BalanceSheetDto; +import com.codenameone.examples.bastion.api.BooksApiServer; +import com.codenameone.examples.bastion.api.ChartAccountDto; +import com.codenameone.examples.bastion.api.CustodyDto; +import com.codenameone.examples.bastion.api.GeneralLedgerDto; +import com.codenameone.examples.bastion.api.IncomeStatementDto; +import com.codenameone.examples.bastion.api.JournalEntryDto; +import com.codenameone.examples.bastion.api.PeriodDto; +import com.codenameone.examples.bastion.api.ReconciliationDto; +import com.codenameone.examples.bastion.api.TAccountDto; +import com.codenameone.examples.bastion.api.TrialBalanceDto; +import com.codenameone.examples.bastion.api.VolumeDayDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.chain.Chain; + +import java.util.List; + +/// [com.codenameone.examples.bastion.api.BooksApi], served: read-only, staff +/// only, and every read on the audit chain. +@Component +public class BooksEndpoint implements BooksApiServer { + private final Books books; + private final Audit audit; + private final Chain chain; + + public BooksEndpoint(Books books, Audit audit, Chain chain) { + this.chain = chain; + this.books = books; + this.audit = audit; + } + + @Override + public CustodyDto custody() throws Exception { + return chain.custodyReport(); + } + + @Override + public List chart() throws Exception { + Staff.name(); + // The chart is the bank's structure, not anybody's data: not audited. + return Chart.all(); + } + + @Override + public List journal(String from, String to, String limit) throws Exception { + long f = from(from); + long t = to(to); + int n = clamp(limit, 50, 200); + Staff.read(audit, "journal", f + ".." + t + " limit " + n); + return books.journal(f, t, n); + } + + @Override + public JournalEntryDto entry(String id) throws Exception { + Staff.read(audit, id, "journal entry"); + return books.entry(id); + } + + @Override + public GeneralLedgerDto generalLedger(String code, String asset, String from, String to) + throws Exception { + long f = from(from); + long t = to(to); + Staff.read(audit, "ledger:" + code + ":" + asset, f + ".." + t); + return books.generalLedger(code, asset, f, t); + } + + @Override + public TAccountDto tAccount(String id, String from, String to) throws Exception { + long f = from(from); + long t = to(to); + Staff.read(audit, id, "t-account " + f + ".." + t); + return books.tAccount(id, f, t); + } + + @Override + public TrialBalanceDto trialBalance(String asOf) throws Exception { + long t = to(asOf); + Staff.read(audit, "trial-balance", "as of " + t); + return books.trialBalance(t); + } + + @Override + public BalanceSheetDto balanceSheet(String asOf) throws Exception { + long t = to(asOf); + Staff.read(audit, "balance-sheet", "as of " + t); + return books.balanceSheet(t); + } + + @Override + public IncomeStatementDto incomeStatement(String from, String to) throws Exception { + long f = from(from); + long t = to(to); + Staff.read(audit, "income-statement", f + ".." + t); + return books.incomeStatement(f, t); + } + + @Override + public ReconciliationDto reconciliation() throws Exception { + String who = Staff.name(); + ReconciliationDto result = books.reconcile(); + // What was found is on the record too, as with the ledger check. + audit.append(who, "RECONCILED", "ledger", result.clean() ? "clean" + : result.breaks().size() + " breaks"); + return result; + } + + @Override + public List periods() throws Exception { + Staff.read(audit, "periods", "closed periods"); + return books.periods(); + } + + @Override + public List volume(String days) throws Exception { + int d = clamp(days, 30, 366); + Staff.read(audit, "volume", d + " days"); + return books.volume(d); + } + + /// Query values arrive as text and are optional: absent or empty is the + /// default, anything that is not a number a 400. + public static long number(String text, long fallback) { + if (text == null || text.trim().length() == 0) { + return fallback; + } + try { + return Long.parseLong(text.trim()); + } catch (NumberFormatException notANumber) { + throw Refused.with(400, "not-number", "That is not a number"); + } + } + + static long from(String from) { + long f = number(from, 0L); + return f < 0 ? 0L : f; + } + + static long to(String to) { + long t = number(to, 0L); + return t <= 0 ? System.currentTimeMillis() : t; + } + + public static int clamp(String value, int fallback, int max) { + long v = number(value, fallback); + if (v <= 0) { + return fallback; + } + return (int) Math.min(v, max); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/Chart.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/Chart.java new file mode 100644 index 00000000000..ac4a10d700f --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/Chart.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.books; + +import com.codenameone.examples.bastion.api.ChartAccountDto; +import com.codenameone.examples.bastion.ledger.AccountKinds; + +import java.util.ArrayList; +import java.util.List; + +/// The chart of accounts: every ledger account kind, seen from the bank's own +/// books. +/// +/// The ledger stores one signed number per posting. Read as accounting, a +/// **positive amount is a credit and a negative one a debit**, which is why +/// every entry summing to zero is the same statement as "debits equal +/// credits". A customer's deposit is money the bank OWES, so customer +/// accounts are liabilities with a credit normal balance; the cash that came +/// in is an asset with a debit normal balance -- the reason `SYS_CASH` reads +/// negative in the raw table. +/// +/// Classes, codes and normal balances follow from the kind, in code rather +/// than a table, so no row can ever put a customer's deposit among the bank's +/// assets. +public final class Chart { + public static final String ASSET = "ASSET"; + public static final String LIABILITY = "LIABILITY"; + public static final String EQUITY = "EQUITY"; + public static final String REVENUE = "REVENUE"; + public static final String EXPENSE = "EXPENSE"; + + public static final String DEBIT = "DEBIT"; + public static final String CREDIT = "CREDIT"; + + /// A kind nothing here knows: shown, never hidden, so a new kind that + /// was not classified shows up on the trial balance at once. + public static final String SUSPENSE_CODE = "9999"; + + private static final String[][] ACCOUNTS = { + {"1000", "Cash and reserves", ASSET, AccountKinds.SYS_CASH}, + {"1100", "Digital assets in custody", ASSET, AccountKinds.SYS_CHAIN}, + {"2000", "Customer deposits - checking", LIABILITY, AccountKinds.CHECKING}, + {"2010", "Customer deposits - savings", LIABILITY, AccountKinds.SAVINGS}, + {"2100", "Customer digital asset custody", LIABILITY, AccountKinds.WALLET}, + {"2200", "Order escrow", LIABILITY, AccountKinds.HOLD}, + {"2300", "Withdrawals in transit", LIABILITY, AccountKinds.SYS_CHAIN_TRANSIT}, + {"3000", "Paid-in capital", EQUITY, AccountKinds.SYS_EQUITY}, + {"3100", "Retained earnings", EQUITY, AccountKinds.SYS_RETAINED}, + {"4000", "Fee and trading revenue", REVENUE, AccountKinds.SYS_FEES}, + {"5000", "Interest expense", EXPENSE, AccountKinds.SYS_INTEREST}, + {"5100", "Operational adjustments", EXPENSE, AccountKinds.SYS_ADJUST}, + {"5200", "Network fees", EXPENSE, AccountKinds.SYS_NETWORK_FEES}, + }; + + private Chart() { + } + + public static List all() { + List out = new ArrayList(); + for (String[] a : ACCOUNTS) { + out.add(new ChartAccountDto(a[0], a[1], a[2], normalOf(a[2]), a[3])); + } + return out; + } + + public static String code(String kind) { + String[] a = find(kind); + return a == null ? SUSPENSE_CODE : a[0]; + } + + public static String name(String kind) { + String[] a = find(kind); + return a == null ? "Suspense (" + kind + ")" : a[1]; + } + + public static String classOf(String kind) { + String[] a = find(kind); + return a == null ? ASSET : a[2]; + } + + /// The kind a code stands for, or null. + public static String kindOf(String code) { + for (String[] a : ACCOUNTS) { + if (a[0].equals(code)) { + return a[3]; + } + } + return null; + } + + public static boolean isCustomer(String kind) { + return AccountKinds.isCustomer(kind); + } + + public static boolean isNominal(String kind) { + String c = classOf(kind); + return REVENUE.equals(c) || EXPENSE.equals(c); + } + + public static String normalOf(String accountClass) { + return ASSET.equals(accountClass) || EXPENSE.equals(accountClass) ? DEBIT : CREDIT; + } + + /// The balance as the class reads it: positive when the account is on + /// its normal side. `net` is the ledger's credit-minus-debit sum. + public static long normal(String kind, long net) { + return DEBIT.equals(normalOf(classOf(kind))) ? -net : net; + } + + public static long debit(long amount) { + return amount < 0 ? -amount : 0L; + } + + public static long credit(long amount) { + return amount > 0 ? amount : 0L; + } + + private static String[] find(String kind) { + for (String[] a : ACCOUNTS) { + if (a[3].equals(kind)) { + return a; + } + } + return null; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/Staff.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/Staff.java new file mode 100644 index 00000000000..fa0c9ac2aca --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/books/Staff.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.books; + +import com.codenameone.examples.bastion.Caller; +import com.codenameone.examples.bastion.Refused; +import com.codenameone.examples.bastion.Roles; +import com.codenameone.examples.bastion.audit.Audit; + +import java.io.IOException; + +/// Who may read the books, and the record that they did. +/// +/// The API chain already limits `/api/books/**` and `/api/audit/**` to ADMIN +/// and AUDITOR; each endpoint asks again here, as the admin endpoints do, +/// because a rule is a line in another file. +public final class Staff { + private Staff() { + } + + /// The caller, when they are an ADMIN or an AUDITOR; 403 otherwise. + public static String name() { + String name = Caller.name(); + if (!Caller.hasRole(Roles.ADMIN) && !Caller.hasRole(Roles.AUDITOR)) { + throw Refused.with(403, "staff-only", "Staff only"); + } + return name; + } + + /// The caller, when they are an ADMIN -- an auditor reads and never + /// operates, which is the point of having one. + public static String admin() { + String name = Caller.name(); + if (!Caller.hasRole(Roles.ADMIN)) { + throw Refused.with(403, "administrators", "Administrators only"); + } + return name; + } + + /// [#name], with the read appended to the audit chain before anything + /// is answered: what was asked, by whom, from where. A read that cannot + /// be recorded is not served. + public static String read(Audit audit, String what, String detail) throws IOException { + String who = name(); + audit.append(who, "STAFF_READ", what, detail == null ? "" : detail); + return who; + } + + /// An ISO-8601 UTC timestamp, for exports a spreadsheet reads. + public static String iso(long ms) { + long days = Books.day(ms) / Books.DAY; + long rest = ms - days * Books.DAY; + // Civil from days (Howard Hinnant's algorithm), proleptic Gregorian. + long z = days + 719468; + long era = (z >= 0 ? z : z - 146096) / 146097; + long doe = z - era * 146097; + long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + long y = yoe + era * 400; + long doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + long mp = (5 * doy + 2) / 153; + long d = doy - (153 * mp + 2) / 5 + 1; + long m = mp < 10 ? mp + 3 : mp - 9; + if (m <= 2) { + y++; + } + StringBuilder b = new StringBuilder(); + b.append(y).append('-').append(two(m)).append('-').append(two(d)).append('T') + .append(two(rest / 3600000)).append(':').append(two(rest / 60000 % 60)) + .append(':').append(two(rest / 1000 % 60)).append('.'); + long millis = rest % 1000; + b.append(millis < 100 ? "0" : "").append(millis < 10 ? "0" : "").append(millis) + .append('Z'); + return b.toString(); + } + + private static String two(long v) { + return v < 10 ? "0" + v : Long.toString(v); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Bip32.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Bip32.java new file mode 100644 index 00000000000..f7995e938de --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Bip32.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +import com.codename1.security.Hmac; + +/// BIP-32 hierarchical deterministic keys, private derivation only. +/// +/// Every customer deposit address is a child of one seed, at the BIP-44 +/// Ethereum path m/44'/60'/0'/0/i. One secret backs them all, so the seed is +/// what goes in an HSM or a sealed backup, and any standard wallet given it +/// finds the same addresses -- the funds survive this server. +public final class Bip32 { + /// The offset that marks a hardened index. + public static final long HARDENED = 0x80000000L; + + private final byte[] key; + private final byte[] chainCode; + + private Bip32(byte[] key, byte[] chainCode) { + this.key = key; + this.chainCode = chainCode; + } + + /// The master key of `seed` (16 to 64 bytes). + public static Bip32 master(byte[] seed) { + if (seed.length < 16 || seed.length > 64) { + throw new IllegalArgumentException("a seed is 16 to 64 bytes"); + } + byte[] i = Hmac.sha512(ascii("Bitcoin seed"), seed); + byte[] k = slice(i, 0); + if (!EthKeys.validPrivate(k)) { + throw new IllegalArgumentException("unusable seed"); + } + return new Bip32(k, slice(i, 32)); + } + + /// The child at `index`; add [#HARDENED] for a hardened one. + public Bip32 child(long index) { + if (index < 0 || index > 0xffffffffL) { + throw new IllegalArgumentException("index out of range"); + } + byte[] data = new byte[37]; + if (index >= HARDENED) { + data[0] = 0; + System.arraycopy(key, 0, data, 1, 32); + } else { + System.arraycopy(EthKeys.compressedPublicKey(key), 0, data, 0, 33); + } + data[33] = (byte) (index >>> 24); + data[34] = (byte) (index >>> 16); + data[35] = (byte) (index >>> 8); + data[36] = (byte) index; + byte[] i = Hmac.sha512(chainCode, data); + byte[] child = EthKeys.addPrivate(key, slice(i, 0)); + if (child == null) { + // Probability below 2^-127; the specification says use the next index. + return child(index + 1); + } + return new Bip32(child, slice(i, 32)); + } + + /// The key at m/44'/60'/0'/0/`index` under `seed`. + public static Bip32 ethereum(byte[] seed, long index) { + return master(seed).child(44 + HARDENED).child(60 + HARDENED).child(HARDENED).child(0) + .child(index); + } + + /// A copy of the 32-byte private key. Wipe it after use. + public byte[] privateKey() { + byte[] out = new byte[32]; + System.arraycopy(key, 0, out, 0, 32); + return out; + } + + public String address() { + return EthAddress.of(EthKeys.publicKey(key)); + } + + private static byte[] slice(byte[] in, int from) { + byte[] out = new byte[32]; + System.arraycopy(in, from, out, 0, 32); + return out; + } + + private static byte[] ascii(String text) { + byte[] out = new byte[text.length()]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) text.charAt(iter); + } + return out; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Chain.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Chain.java new file mode 100644 index 00000000000..21f0bb03d13 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Chain.java @@ -0,0 +1,783 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +import java.time.Instant; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.api.CustodyDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.ChainCursor; +import com.codenameone.examples.bastion.domain.ChainTransfer; +import com.codenameone.examples.bastion.domain.CryptoAddress; +import com.codenameone.examples.bastion.domain.DepositAddress; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codenameone.examples.bastion.ledger.Leg; +import com.codenameone.examples.bastion.repository.ChainCursorRepository; +import com.codenameone.examples.bastion.repository.ChainTransferRepository; +import com.codenameone.examples.bastion.repository.DepositAddressRepository; +import com.codenameone.examples.bastion.repository.LedgerAccountRepository; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +/// Real Ether, on a real Ethereum network: deposits credited from the chain, +/// withdrawals signed here and broadcast, and every coin that crosses posted +/// to the ledger exactly once. +/// +/// Off unless `bastion.chain.rpc-url` names a node; the sample then keeps its +/// simulated chain (SYS_CHAIN as the far side of every withdrawal). On, ETH +/// moves for real -- on a test network or a local development chain for the +/// sample, on mainnet for a product that has done what the implementor's +/// guide lists first. +/// +/// **Custody.** One seed (`bastion.chain.seed`, 32 bytes of hex) backs every +/// key, through BIP-32: customer deposit addresses at m/44'/60'/0'/0/i and the +/// hot wallet withdrawals are paid from at m/44'/60'/1'/0/0. A product keeps +/// the seed in an HSM or a cloud key service and replaces [ChainSigner]'s +/// in-process implementation with one that asks it to sign; nothing else here +/// would change. The hot wallet should hold only what a day of withdrawals +/// needs, the rest moved to cold storage by hand. +/// +/// **Withdrawals** are a small state machine (see [ChainTransfer]). The +/// customer's coins go to SYS_CHAIN_TRANSIT in the same transaction that +/// queues the row. A transaction is signed with a fixed nonce and the signed +/// bytes are saved *before* the first broadcast, so a crash at any point +/// re-broadcasts the same transaction instead of signing a second spend. It +/// is final after `bastion.chain.confirmations` blocks, and only if the block +/// that holds it is still the canonical one at that height. +/// +/// **Deposits** are read from blocks already `confirmations` deep, so they are +/// never credited from a block that is later reorganized away (deeper reorgs +/// stop the scanner for a person to look at: see [#scan]). A deposit is a +/// transaction whose `to` is a customer's deposit address and whose receipt +/// says it succeeded. Ether sent by a contract (internal transactions) is not +/// seen by this scan; it needs a tracing node, and until then the scheduled +/// sweep still moves it, so it shows up as unexplained balance to reconcile. +/// +/// **Postings.** Customer -> SYS_CHAIN_TRANSIT when queued; TRANSIT -> +/// SYS_CHAIN when final, or back to the customer if the transaction failed; +/// SYS_CHAIN -> customer for a deposit; SYS_NETWORK_FEES -> SYS_CHAIN for +/// every fee paid. Each posting's idempotency key names the row and the step, +/// so running a step twice moves nothing the second time. +@Component +public class Chain { + public static final String ASSET = "ETH"; + static final String OUT = "OUT"; + static final String IN = "IN"; + static final String SWEEP = "SWP"; + static final String QUEUED = "QUEUED"; + static final String SIGNED = "SIGNED"; + static final String BROADCAST = "BROADCAST"; + static final String CONFIRMED = "CONFIRMED"; + static final String FAILED = "FAILED"; + /// A plain Ether transfer to an account with no code. + static final long TRANSFER_GAS = 21000L; + private static final String CURSOR = "ETH"; + private static final String HD_COUNTER = "ETH-HD"; + /// Blocks read per tick, so one tick never holds the scheduler for long. + private static final int BLOCKS_PER_TICK = 64; + + private final Ledger ledger; + private final Audit audit; + private final ChainTransferRepository transfers; + private final DepositAddressRepository deposits; + private final ChainCursorRepository cursors; + private final LedgerAccountRepository accounts; + private final String rpcUrl; + private final long expectedChainId; + private final int confirmations; + private final String seedHex; + private final long rebroadcastMillis; + private final long sweepMinimumGwei; + private static final String LEASE = "ETH-LEASE"; + /// A worker that has not renewed its lease for this long is presumed + /// dead and another server takes over. + private static final long LEASE_MILLIS = 5L * 60L * 1000L; + /// Keeps this server's own ticks from overlapping. + private final AtomicBoolean running = new AtomicBoolean(); + private final String instance; + private EthRpc rpc; + private ChainSigner signer; + + public Chain(Ledger ledger, Audit audit, ChainTransferRepository transfers, + DepositAddressRepository deposits, ChainCursorRepository cursors, + LedgerAccountRepository accounts, + @Value("${bastion.chain.rpc-url:}") String rpcUrl, + @Value("${bastion.chain.id:11155111}") String chainId, + @Value("${bastion.chain.confirmations:12}") String confirmations, + @Value("${bastion.chain.seed:}") String seedHex, + @Value("${bastion.chain.rebroadcast-seconds:300}") String rebroadcastSeconds, + @Value("${bastion.chain.sweep-minimum-gwei:1000000}") String sweepMinimumGwei) { + this.ledger = ledger; + this.audit = audit; + this.transfers = transfers; + this.deposits = deposits; + this.cursors = cursors; + this.accounts = accounts; + this.rpcUrl = rpcUrl.trim(); + this.expectedChainId = Long.parseLong(chainId.trim()); + this.confirmations = Math.max(1, Integer.parseInt(confirmations.trim())); + this.seedHex = seedHex.trim(); + this.rebroadcastMillis = Long.parseLong(rebroadcastSeconds.trim()) * 1000L; + this.sweepMinimumGwei = Long.parseLong(sweepMinimumGwei.trim()); + try { + this.instance = Ids.next(); + } catch (IOException impossible) { + throw new IllegalStateException(impossible); + } + } + + /// Connects, and refuses to start against the wrong network: a seed + /// configured for a test network must never sign on mainnet by a typo in + /// the URL, nor the reverse. + @PostConstruct + public void start() throws IOException { + if (rpcUrl.length() == 0) { + return; + } + if (seedHex.length() != 64) { + throw new IllegalStateException("bastion.chain.seed must be 32 bytes of hex"); + } + rpc = new EthRpc(rpcUrl); + long actual = rpc.chainId(); + if (actual != expectedChainId) { + throw new IllegalStateException("the node is on chain " + actual + + ", bastion.chain.id says " + expectedChainId); + } + signer = new SeedSigner(Hex32.decode(seedHex)); + // The cursor exists before the first address is handed out. Made + // lazily by the first scan instead, it would start at the head of + // that moment, and a deposit sent between the address being shown + // and that scan would sit in a block nothing ever reads. + cursor(Math.max(0, rpc.blockNumber() - confirmations + 1)); + } + + /// True when ETH moves on a real chain. Read from the configuration, not + /// from [#start], so a component that asks during its own start-up gets + /// the right answer whichever starts first. + public boolean enabled() { + return rpcUrl.length() > 0; + } + + public boolean handles(String asset) { + return enabled() && ASSET.equals(asset); + } + + /// The address customers' withdrawals are paid from, so an operator can + /// fund it and watch it. + public String hotWallet() { + return signer.hotAddress(); + } + + public long chainId() { + return expectedChainId; + } + + public int confirmations() { + return confirmations; + } + + /// The network's name, for people: a customer must be able to tell a + /// test network from the real one before sending anything. + public String network() { + if (expectedChainId == 1) { + return "Ethereum"; + } + if (expectedChainId == 11155111) { + return "Sepolia test network"; + } + if (expectedChainId == 17000) { + return "Holesky test network"; + } + return "Development chain " + expectedChainId; + } + + /// [CustodyDto] for the back office. + @Transactional(readOnly = true) + public CustodyDto custodyReport() throws IOException { + if (!enabled()) { + return new CustodyDto(false, ASSET, 0, "", 0, 0, 0, 0); + } + long owed = 0; + long transit = 0; + for (LedgerAccount a : accounts.findByAsset(ASSET)) { + if (AccountKinds.SYS_CHAIN_TRANSIT.equals(a.getKind())) { + transit += a.getBalance(); + } + if (!AccountKinds.BANK.equals(a.getOwner()) + || AccountKinds.SYS_CHAIN_TRANSIT.equals(a.getKind())) { + owed += a.getBalance(); + } + } + return new CustodyDto(true, ASSET, expectedChainId, EthAddress.checksum(signer.hotAddress()), + Wei.toGwei(rpc.balance(signer.hotAddress())), custody(), owed, transit); + } + + // ------------------------------------------------------------- deposits + + /// `owner`'s deposit address, derived and stored the first time. + @Transactional(rollbackFor = Exception.class) + public DepositAddress depositAddress(String owner) throws IOException { + List mine = deposits.findByOwnerAndAsset(owner, ASSET); + if (!mine.isEmpty()) { + return mine.get(0); + } + // The next index, behind the counter row: two customers asking at + // once must not be given one address. + ChainCursor counter = cursors.findById(HD_COUNTER); + if (counter == null) { + counter = new ChainCursor(); + counter.setChain(HD_COUNTER); + counter.setLastHash(""); + cursors.insert(counter); + } + long index = counter.getNextBlock(); + counter.setNextBlock(index + 1); + counter.setUpdatedAt(Instant.now()); + cursors.save(counter); + DepositAddress d = new DepositAddress(); + d.setAddress(signer.depositAddress(index)); + d.setOwner(owner); + d.setAsset(ASSET); + d.setHdIndex(index); + d.setCreatedAt(Instant.now()); + deposits.insert(d); + audit.append(owner, "DEPOSIT_ADDRESS", d.getAddress(), "index " + index); + return d; + } + + /// Reads the blocks that became `confirmations` deep since the last tick + /// and credits every deposit in them. + /// + /// The cursor remembers the hash of the last block read. A block whose + /// parent is not that hash means the chain was reorganized deeper than + /// the confirmation depth -- something that should not happen at a sane + /// depth and that may already have credited a deposit that no longer + /// exists. The scanner stops there, audits it, and waits for a person. + @SuppressWarnings("unchecked") + public void scan() throws IOException { + long safeHead = rpc.blockNumber() - confirmations + 1; + ChainCursor cursor = cursor(safeHead); + long end = Math.min(safeHead, cursor.getNextBlock() + BLOCKS_PER_TICK - 1); + for (long n = cursor.getNextBlock(); n <= end; n++) { + Map block = rpc.block(n); + if (block == null) { + return; + } + String parent = (String) block.get("parentHash"); + if (cursor.getLastHash().length() > 0 && !cursor.getLastHash().equalsIgnoreCase(parent)) { + audit.append("@chain", "CHAIN_REORG", String.valueOf(n), + "parent " + parent + " is not " + cursor.getLastHash()); + throw new IOException("chain reorganized below the confirmation depth at block " + n); + } + for (Object o : (List) block.get("transactions")) { + Map tx = (Map) o; + String to = (String) tx.get("to"); + if (to == null) { + continue; + } + DepositAddress d = depositAt(to.toLowerCase()); + if (d == null) { + continue; + } + byte[] value = Wei.parseQuantity((String) tx.get("value")); + long gwei = Wei.toGwei(value); + if (gwei <= 0) { + continue; + } + Map receipt = rpc.receipt((String) tx.get("hash")); + if (receipt == null || !"0x1".equals(receipt.get("status"))) { + // Included but reverted: no value moved. + continue; + } + credit(d, (String) tx.get("hash"), gwei, n, (String) block.get("hash")); + } + advance(cursor, n + 1, (String) block.get("hash")); + } + } + + @Transactional(rollbackFor = Exception.class) + private ChainCursor cursor(long safeHead) throws IOException { + ChainCursor c = cursors.findById(CURSOR); + if (c == null) { + // A new bank starts reading at the current safe head: there are + // no deposit addresses before it, so nothing earlier is ours. + c = new ChainCursor(); + c.setChain(CURSOR); + c.setNextBlock(Math.max(0, safeHead)); + c.setLastHash(""); + c.setUpdatedAt(Instant.now()); + cursors.insert(c); + } + return c; + } + + @Transactional(rollbackFor = Exception.class) + private void advance(ChainCursor c, long next, String hash) throws IOException { + c.setNextBlock(next); + c.setLastHash(hash); + c.setUpdatedAt(Instant.now()); + cursors.save(c); + } + + /// Credits one deposit, once: the row's unique transaction hash and the + /// posting's idempotency key both refuse a second. + @Transactional(rollbackFor = Exception.class) + private void credit(DepositAddress d, String txHash, long gwei, long blockNumber, + String blockHash) throws IOException { + if (!transfers.findByDirectionAndTxHash(IN, txHash).isEmpty()) { + return; + } + LedgerAccount wallet = wallet(d.getOwner()); + LedgerAccount chain = ledger.system(ASSET, AccountKinds.SYS_CHAIN); + String id = Ids.next(); + ledger.post("chain:in:" + txHash, "CRYPTO_IN", d.getOwner(), "from the chain " + txHash, + Arrays.asList(new Leg(chain.getId(), -gwei), new Leg(wallet.getId(), gwei))); + ChainTransfer t = row(id, IN, d.getOwner(), d.getAddress(), gwei, CONFIRMED); + t.setTxHash(txHash); + t.setBlockNumber(blockNumber); + t.setBlockHash(blockHash); + t.setReference(d.getAddress().substring(2, 34)); + transfers.insert(t); + audit.append(d.getOwner(), "CRYPTO_IN", txHash, gwei + " " + ASSET + " at block " + blockNumber); + } + + // ---------------------------------------------------------- withdrawals + + /// Queues the withdrawal the ledger has just moved into transit, in the + /// caller's transaction. `entryId` is that movement's journal entry, + /// which makes the row's id: a retried request finds the row it made. + public void queue(String entryId, String owner, CryptoAddress to, long gwei) throws IOException { + if (transfers.findById(entryId) != null) { + return; + } + if (!EthAddress.valid(to.getAddress())) { + // CryptoWallets checks this when the address is added; an address + // added while the chain was simulated may predate the check. + throw new IllegalArgumentException("not an Ethereum address"); + } + ChainTransfer t = row(entryId, OUT, owner, EthAddress.normalize(to.getAddress()), gwei, QUEUED); + t.setReference(to.getId()); + transfers.insert(t); + } + + /// The scheduler's whole job, in an order that keeps money safe if any + /// step stops halfway: sign what is queued, broadcast what is signed, + /// settle what is mined, then read new deposits and sweep them. + /// + /// One worker at a time across every server: the lease row is taken with + /// a single conditional update, and every state change below is itself a + /// compare-and-set, so even two workers that both believe they hold the + /// lease cannot sign one withdrawal twice. + @Scheduled(fixedRateString = "${bastion.chain.period-ms:15000}") + public void tick() { + if (!enabled() || !running.compareAndSet(false, true)) { + return; + } + try { + if (!lease()) { + return; + } + for (ChainTransfer t : inState(QUEUED)) { + sign(t); + } + for (ChainTransfer t : inState(SIGNED)) { + broadcast(t); + } + for (ChainTransfer t : inState(BROADCAST)) { + settle(t); + } + scan(); + sweep(); + } catch (IOException | RuntimeException failed) { + // The next tick starts over from the rows; nothing is half done. + try { + audit.append("@chain", "CHAIN_TICK_FAILED", "", String.valueOf(failed.getMessage())); + } catch (IOException notRecorded) { + // The database is what failed; the next tick tries again. + } + } finally { + running.set(false); + } + } + + @Transactional(rollbackFor = Exception.class) + private boolean lease() throws IOException { + if (cursors.findById(LEASE) == null) { + ChainCursor row = new ChainCursor(); + row.setChain(LEASE); + row.setLastHash(""); + cursors.insert(row); + } + long now = System.currentTimeMillis(); + return cursors.lease(LEASE, instance, Instant.ofEpochMilli(now), Instant.ofEpochMilli(now - LEASE_MILLIS)) == 1; + } + + /// Fixes a nonce and fees and signs, saving the signed bytes before any + /// broadcast. + @Transactional(rollbackFor = Exception.class) + private void sign(ChainTransfer t) throws IOException { + if (OUT.equals(t.getDirection())) { + String shortfall = reserves(t); + if (shortfall != null) { + // Stays QUEUED: the customer's coins wait in transit, nothing + // is lost, and a person decides. Paying it would spend coins + // that belong to other customers. + if (!shortfall.equals(t.getDetail())) { + t.setDetail(shortfall); + save(t); + audit.append("@chain", "CHAIN_RESERVES_SHORT", t.getId(), shortfall); + } + return; + } + } + String from = OUT.equals(t.getDirection()) ? signer.hotAddress() : t.getAddress(); + long nonce = nextNonce(from); + byte[] priority = rpc.priorityFee(); + // Twice the next base fee plus the tip: enough to survive several + // full blocks of rising fees; unused headroom is not charged. + byte[] maxFee = add(Wei.mulSmall(rpc.nextBaseFee(), 2), priority); + byte[] value = Wei.fromGwei(t.getAmount()); + String to = OUT.equals(t.getDirection()) ? t.getAddress() : signer.hotAddress(); + Eip1559Tx tx; + byte[] signature; + if (SWEEP.equals(t.getDirection())) { + // A sweep sends everything but its own fee. + Eip1559Tx probe = new Eip1559Tx(expectedChainId, nonce, priority, maxFee, TRANSFER_GAS, + to, value, null); + long feeGwei = ceilGwei(probe.maxCost()); + long send = t.getAmount() - feeGwei; + if (send <= 0) { + finish(t, FAILED, "too little to pay its own fee"); + return; + } + tx = new Eip1559Tx(expectedChainId, nonce, priority, maxFee, TRANSFER_GAS, to, + Wei.fromGwei(send), null); + signature = signer.signWithDeposit(depositIndex(t.getAddress()), tx.signingHash()); + t.setAmount(send); + } else { + tx = new Eip1559Tx(expectedChainId, nonce, priority, maxFee, TRANSFER_GAS, to, value, null); + signature = signer.signWithHot(tx.signingHash()); + } + byte[] raw = tx.raw(signature); + // Saved before any broadcast, and only over the QUEUED row it was + // made from: a second worker's signature is dropped here, unsent. + transfers.signed(t.getId(), QUEUED, SIGNED, nonce, Hex32.encode(raw), Eip1559Tx.hash(raw), + t.getAmount(), Instant.now()); + } + + /// The node's pending nonce, or one past the highest this bank has + /// signed from `from` if that is higher -- a transaction signed but not + /// yet seen by the node must not have its nonce given to another. + @Transactional(readOnly = true) + private long nextNonce(String from) throws IOException { + long next = rpc.pendingNonce(from); + for (String status : new String[] {SIGNED, BROADCAST}) { + for (ChainTransfer t : transfers.findByStatusOrderByCreatedAtAsc(status)) { + String sender = OUT.equals(t.getDirection()) ? signer.hotAddress() : t.getAddress(); + if (sender.equals(from) && t.getNonce() >= next) { + next = t.getNonce() + 1; + } + } + } + return next; + } + + @Transactional(rollbackFor = Exception.class) + private void broadcast(ChainTransfer t) throws IOException { + try { + rpc.sendRaw(Hex32.decode(t.getRaw())); + } catch (EthRpc.NodeException refused) { + String m = String.valueOf(refused.getMessage()); + // The node already has it: from an earlier broadcast whose answer + // was lost. Anything else stays SIGNED and is retried; the same + // bytes can be sent any number of times. + if (!m.contains("already known") && !m.contains("nonce too low")) { + t.setDetail(m.length() > 200 ? m.substring(0, 200) : m); + save(t); + return; + } + } + if (transfers.advance(t.getId(), SIGNED, BROADCAST, Instant.now()) == 1) { + t.setStatus(BROADCAST); + t.setBroadcastAt(Instant.now()); + t.setUpdatedAt(t.getBroadcastAt()); + save(t); + } + } + + /// Final once its block is `confirmations` deep and still canonical. + @SuppressWarnings("unchecked") + private void settle(ChainTransfer t) throws IOException { + Map receipt = rpc.receipt(t.getTxHash()); + if (receipt == null) { + if (System.currentTimeMillis() - t.getBroadcastAt().toEpochMilli() > rebroadcastMillis) { + // Dropped from a node's pool: send the same bytes again. + try { + rpc.sendRaw(Hex32.decode(t.getRaw())); + } catch (EthRpc.NodeException ignored) { + // "already known" and "nonce too low" both mean wait. + } + t.setBroadcastAt(Instant.now()); + save(t); + } + return; + } + long mined = Wei.parseLong((String) receipt.get("blockNumber")); + if (rpc.blockNumber() - mined + 1 < confirmations) { + return; + } + Map canonical = rpc.block(mined); + if (canonical == null || !String.valueOf(canonical.get("hash")) + .equalsIgnoreCase((String) receipt.get("blockHash"))) { + // Its block was replaced; the transaction will be mined again + // (same nonce, same bytes) or its receipt will disappear. + return; + } + long gasUsed = Wei.parseLong((String) receipt.get("gasUsed")); + byte[] price = Wei.parseQuantity((String) receipt.get("effectiveGasPrice")); + long fee = ceilGwei(Wei.mulSmall(price, gasUsed)); + t.setBlockNumber(mined); + t.setBlockHash((String) receipt.get("blockHash")); + t.setFee(fee); + finish(t, "0x1".equals(receipt.get("status")) ? CONFIRMED : FAILED, ""); + } + + /// Posts a final state once. A withdrawal that failed on the chain goes + /// back to the customer; the fee is the bank's either way. + @Transactional(rollbackFor = Exception.class) + private void finish(ChainTransfer t, String status, String detail) throws IOException { + if (transfers.advance(t.getId(), t.getStatus(), status, Instant.now()) != 1) { + // Another worker settled it; its postings stand. + return; + } + LedgerAccount chain = ledger.system(ASSET, AccountKinds.SYS_CHAIN); + if (OUT.equals(t.getDirection())) { + LedgerAccount transit = ledger.system(ASSET, AccountKinds.SYS_CHAIN_TRANSIT); + LedgerAccount back = CONFIRMED.equals(status) ? chain : wallet(t.getOwner()); + ledger.post("chain:out:" + t.getId() + ":" + status, CONFIRMED.equals(status) + ? "CRYPTO_OUT_FINAL" : "CRYPTO_OUT_FAILED", t.getOwner(), + (CONFIRMED.equals(status) ? "final " : "failed, returned ") + t.getTxHash(), + Arrays.asList(new Leg(transit.getId(), -t.getAmount()), + new Leg(back.getId(), t.getAmount()))); + } + if (t.getFee() > 0) { + LedgerAccount fees = ledger.system(ASSET, AccountKinds.SYS_NETWORK_FEES); + ledger.post("chain:fee:" + t.getId(), "NETWORK_FEE", "@chain", "gas for " + t.getTxHash(), + Arrays.asList(new Leg(fees.getId(), -t.getFee()), new Leg(chain.getId(), t.getFee()))); + } + t.setStatus(status); + if (detail.length() > 0) { + t.setDetail(detail); + } + t.setUpdatedAt(Instant.now()); + transfers.save(t); + audit.append(t.getOwner(), "CHAIN_" + t.getDirection() + "_" + status, t.getTxHash(), + t.getAmount() + " " + ASSET + " fee " + t.getFee()); + } + + /// Null when the chain holds every coin the ledger owes; otherwise why + /// not. The check every withdrawal passes before it is signed. + /// + /// The ledger owes customers the balances of every non-bank ETH account + /// plus what is in transit. The chain holds the hot wallet and every + /// deposit address. If the first is ever larger -- coins minted in the + /// ledger by a bug, an operator's mistake or an attacker, or a hot wallet + /// emptied by someone holding its key -- no withdrawal is signed until a + /// person has looked, so one customer can never be paid with another's + /// coins. The hot wallet itself must also cover this payment and its + /// worst-case fee. A product keeps the total as a maintained aggregate + /// rather than adding up every account, and alerts on it. + @Transactional(readOnly = true) + String reserves(ChainTransfer t) throws IOException { + long owed = 0; + for (LedgerAccount a : accounts.findByAsset(ASSET)) { + if (!AccountKinds.BANK.equals(a.getOwner()) + || AccountKinds.SYS_CHAIN_TRANSIT.equals(a.getKind())) { + owed += a.getBalance(); + } + } + long held = custody(); + if (owed > held) { + return "the ledger owes " + owed + " gwei and the chain holds " + held; + } + long hot = Wei.toGwei(rpc.balance(signer.hotAddress())); + long worstFee = ceilGwei(Wei.mulSmall(Wei.mulSmall(rpc.nextBaseFee(), 2), TRANSFER_GAS)) + + ceilGwei(Wei.mulSmall(rpc.priorityFee(), TRANSFER_GAS)); + if (hot < t.getAmount() + worstFee) { + return "the hot wallet holds " + hot + " gwei; this needs " + (t.getAmount() + worstFee); + } + return null; + } + + // ---------------------------------------------------------------- sweep + + /// Moves deposits into the hot wallet once an address holds more than + /// `bastion.chain.sweep-minimum-gwei`, so withdrawals are paid from one + /// place and customers' addresses hold only what just arrived. A sweep + /// moves no customer's money -- it is the bank's coins changing address -- + /// so the ledger sees only its fee. + private void sweep() throws IOException { + // One in flight per address: its balance is what the next one reads. + for (DepositAddress d : allDeposits()) { + if (sweeping(d.getAddress())) { + continue; + } + long gwei = Wei.toGwei(rpc.balance(d.getAddress())); + if (gwei < sweepMinimumGwei) { + continue; + } + ChainTransfer t = row(Ids.next(), SWEEP, "@bank", d.getAddress(), gwei, QUEUED); + t.setReference(String.valueOf(d.getHdIndex())); + save(t); + } + } + + @Transactional(readOnly = true) + private boolean sweeping(String address) throws IOException { + for (String status : new String[] {QUEUED, SIGNED, BROADCAST}) { + for (ChainTransfer t : transfers.findByStatusOrderByCreatedAtAsc(status)) { + if (SWEEP.equals(t.getDirection()) && t.getAddress().equals(address)) { + return true; + } + } + } + return false; + } + + @Transactional(readOnly = true) + private long depositIndex(String address) throws IOException { + DepositAddress d = deposits.findById(address); + if (d == null) { + throw new IOException("no deposit address " + address); + } + return d.getHdIndex(); + } + + // -------------------------------------------------------------- helpers + + /// What the bank's books and the chain each say it holds in custody: the + /// proof an auditor can check against any block explorer. + @Transactional(readOnly = true) + public long custody() throws IOException { + long onChain = Wei.toGwei(rpc.balance(signer.hotAddress())); + for (DepositAddress d : deposits.findAll()) { + onChain += Wei.toGwei(rpc.balance(d.getAddress())); + } + return onChain; + } + + /// One row, for the back office and the tests. + @Transactional(readOnly = true) + public ChainTransfer transfer(String id) throws IOException { + return transfers.findById(id); + } + + /// Every row, for the back office and the tests. + @Transactional(readOnly = true) + public List transfers() throws IOException { + return transfers.findAll(); + } + + @Transactional(readOnly = true) + public List history(String owner) throws IOException { + return transfers.findByOwnerOrderByCreatedAtDesc(owner); + } + + @Transactional(rollbackFor = Exception.class) + private void save(ChainTransfer t) throws IOException { + transfers.save(t); + } + + @Transactional(readOnly = true) + private LedgerAccount wallet(String owner) throws IOException { + for (LedgerAccount a : ledger.accountsOf(owner)) { + if (ASSET.equals(a.getAsset()) && AccountKinds.WALLET.equals(a.getKind())) { + return a; + } + } + return ledger.open(owner, ASSET, AccountKinds.WALLET, AccountKinds.WALLET); + } + + private static ChainTransfer row(String id, String direction, String owner, String address, + long gwei, String status) { + ChainTransfer t = new ChainTransfer(); + long now = System.currentTimeMillis(); + t.setId(id); + t.setDirection(direction); + t.setOwner(owner); + t.setAsset(ASSET); + t.setAddress(address); + t.setAmount(gwei); + t.setStatus(status); + t.setTxHash("unsigned:" + id); + t.setNonce(-1); + t.setCreatedAt(Instant.ofEpochMilli(now)); + t.setUpdatedAt(Instant.ofEpochMilli(now)); + return t; + } + + /// Fees round up: the books may say the bank paid a fraction of a gwei + /// more than it did, never less, so they never claim coins it spent. + // Reads the tick makes between node calls, each in a short transaction + // of its own rather than one held open across the network. + @Transactional(readOnly = true) + private List inState(String status) throws IOException { + return transfers.findByStatusOrderByCreatedAtAsc(status); + } + + @Transactional(readOnly = true) + private DepositAddress depositAt(String address) throws IOException { + return deposits.findById(address); + } + + @Transactional(readOnly = true) + private List allDeposits() throws IOException { + return deposits.findAll(); + } + + static long ceilGwei(byte[] wei) { + long g = Wei.toGwei(wei); + return Wei.hasDust(wei) ? g + 1 : g; + } + + static byte[] add(byte[] a, byte[] b) { + int n = Math.max(a.length, b.length) + 1; + byte[] out = new byte[n]; + int carry = 0; + for (int iter = 0; iter < n; iter++) { + int x = iter < a.length ? a[a.length - 1 - iter] & 0xff : 0; + int y = iter < b.length ? b[b.length - 1 - iter] & 0xff : 0; + int sum = x + y + carry; + out[n - 1 - iter] = (byte) sum; + carry = sum >> 8; + } + return Wei.trim(out); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/ChainSigner.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/ChainSigner.java new file mode 100644 index 00000000000..106cf858563 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/ChainSigner.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +/// Everything that touches a chain private key, behind one interface. +/// +/// [SeedSigner] keeps the seed in this process, which is right for a test +/// network and wrong for real money. A product implements this over an HSM or +/// a cloud key service that holds secp256k1 keys and signs a 32-byte digest +/// (AWS KMS and Google Cloud KMS both offer `ECC_SECG_P256K1`): the key then +/// never exists in the server's memory, and every signature is logged by a +/// system the application cannot rewrite. The service answers DER; recover +/// the id by trying both values against the known public key. +public interface ChainSigner { + /// The address withdrawals are paid from. + String hotAddress(); + + /// The customer deposit address at `index`. + String depositAddress(long index); + + /// r || s || recovery id over `hash32`, with the hot wallet key. + byte[] signWithHot(byte[] hash32); + + /// The same with the deposit key at `index`, to sweep it. + byte[] signWithDeposit(long index, byte[] hash32); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/CoreCrypto.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/CoreCrypto.java new file mode 100644 index 00000000000..18e2785d241 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/CoreCrypto.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +import com.codename1.security.Hash; +import com.codename1.security.Secp256k1; + +/// Binds [EthKeys] to the framework's primitives. +final class CoreCrypto { + private CoreCrypto() { + } + + static byte[] keccak256(byte[] data) { + return Hash.keccak256(data); + } + + /// The 64 bytes of x and y, without the 0x04 prefix an address hashes over. + static byte[] publicKey64(byte[] key) { + return strip(Secp256k1.publicKey(key, false)); + } + + static byte[] publicKey33(byte[] key) { + return Secp256k1.publicKey(key, true); + } + + /// r, s (low-S) and the recovery id, 65 bytes. + static byte[] signRecoverable(byte[] key, byte[] hash) { + Secp256k1.EcdsaSignature sig = Secp256k1.sign(hash, key).normalize(); + byte[] out = new byte[65]; + System.arraycopy(sig.toCompact(), 0, out, 0, 64); + out[64] = (byte) sig.recoveryId(); + return out; + } + + /// The signer's 64-byte key, or null when no key fits the signature. + static byte[] recover64(byte[] hash, byte[] sig) { + byte[] compact = new byte[64]; + System.arraycopy(sig, 0, compact, 0, 64); + byte[] pub = Secp256k1.recoverPublicKey(hash, + Secp256k1.EcdsaSignature.fromCompact(compact, sig[64] & 0xff), false); + return pub == null ? null : strip(pub); + } + + static byte[] tweakAdd(byte[] key, byte[] tweak) { + return Secp256k1.privateKeyTweakAdd(key, tweak); + } + + static boolean validPrivate(byte[] key) { + return Secp256k1.isValidPrivateKey(key); + } + + private static byte[] strip(byte[] uncompressed) { + byte[] out = new byte[64]; + System.arraycopy(uncompressed, 1, out, 0, 64); + return out; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Eip1559Tx.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Eip1559Tx.java new file mode 100644 index 00000000000..f6ae90503e0 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Eip1559Tx.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +import java.util.ArrayList; +import java.util.List; + +/// An EIP-1559 (type 2) Ethereum transaction: built, signed and serialized +/// here, so the node receives finished bytes and never a key. +/// +/// The signed payload is `keccak256(0x02 || rlp([chainId, nonce, +/// maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, +/// accessList]))`. The chain id inside it is the replay protection: a +/// withdrawal signed for a test network cannot be replayed on mainnet, and +/// the reverse. The access list is always empty. +public final class Eip1559Tx { + private final long chainId; + private final long nonce; + private final byte[] maxPriorityFee; + private final byte[] maxFee; + private final long gasLimit; + private final byte[] to; + private final byte[] value; + private final byte[] data; + + public Eip1559Tx(long chainId, long nonce, byte[] maxPriorityFee, byte[] maxFee, + long gasLimit, String to, byte[] value, byte[] data) { + if (chainId <= 0 || nonce < 0 || gasLimit <= 0) { + throw new IllegalArgumentException("bad transaction field"); + } + this.chainId = chainId; + this.nonce = nonce; + this.maxPriorityFee = Wei.trim(maxPriorityFee); + this.maxFee = Wei.trim(maxFee); + this.gasLimit = gasLimit; + this.to = EthAddress.bytes(to); + this.value = Wei.trim(value); + this.data = data == null ? new byte[0] : data; + } + + private List fields() { + List f = new ArrayList(); + f.add(Rlp.quantity(chainId)); + f.add(Rlp.quantity(nonce)); + f.add(Rlp.quantity(maxPriorityFee)); + f.add(Rlp.quantity(maxFee)); + f.add(Rlp.quantity(gasLimit)); + f.add(Rlp.string(to)); + f.add(Rlp.quantity(value)); + f.add(Rlp.string(data)); + f.add(Rlp.list(new ArrayList())); + return f; + } + + /// The 32-byte hash a signer signs. + public byte[] signingHash() { + return EthKeys.keccak256(typed(Rlp.list(fields()))); + } + + /// The broadcastable bytes, given the 65-byte r || s || recovery id + /// signature of [#signingHash]. + public byte[] raw(byte[] signature) { + if (signature.length != 65 || (signature[64] & 0xff) > 1) { + throw new IllegalArgumentException("not a recoverable signature"); + } + byte[] r = new byte[32]; + byte[] s = new byte[32]; + System.arraycopy(signature, 0, r, 0, 32); + System.arraycopy(signature, 32, s, 0, 32); + List f = fields(); + f.add(Rlp.quantity(signature[64] & 0xff)); + f.add(Rlp.quantity(r)); + f.add(Rlp.quantity(s)); + return typed(Rlp.list(f)); + } + + /// The transaction hash nodes and explorers know it by. + public static String hash(byte[] raw) { + return "0x" + Hex32.encode(EthKeys.keccak256(raw)); + } + + /// The upper bound this transaction can cost in fees, in wei. + public byte[] maxCost() { + return Wei.mulSmall(maxFee, gasLimit); + } + + private static byte[] typed(byte[] rlp) { + return Rlp.concat(new byte[] {0x02}, rlp); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/EthAddress.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/EthAddress.java new file mode 100644 index 00000000000..6b6e5a0eca7 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/EthAddress.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +/// Ethereum addresses: derived from a public key, and checked when a person +/// types one. +/// +/// An address is the last 20 bytes of the Keccak-256 of the public key. EIP-55 +/// spells it in mixed case so that the case is a checksum: an address typed in +/// mixed case must match it exactly, or one mistyped character is caught +/// before coins go to an address nobody holds. An all-lowercase or +/// all-uppercase address carries no checksum and is accepted as it is, as +/// every wallet does -- the cooling-off on a new withdrawal address is what +/// gives the customer time to notice one they did not mean. +public final class EthAddress { + private EthAddress() { + } + + /// The lowercase `0x` address of a 64-byte public key. + public static String of(byte[] publicKey64) { + byte[] hash = EthKeys.keccak256(publicKey64); + byte[] address = new byte[20]; + System.arraycopy(hash, 12, address, 0, 20); + return "0x" + Hex32.encode(address); + } + + /// `address` in EIP-55 mixed case. + public static String checksum(String address) { + String lower = normalize(address).substring(2); + String hash = Hex32.encode(EthKeys.keccak256(ascii(lower))); + StringBuilder out = new StringBuilder("0x"); + for (int iter = 0; iter < lower.length(); iter++) { + char c = lower.charAt(iter); + if (c >= 'a' && c <= 'f' && hash.charAt(iter) >= '8') { + out.append((char) (c - 'a' + 'A')); + } else { + out.append(c); + } + } + return out.toString(); + } + + /// True when `address` is 0x and 40 hex digits, and -- when written in + /// mixed case -- its case is the EIP-55 checksum. + public static boolean valid(String address) { + if (address == null || address.length() != 42 || !address.startsWith("0x")) { + return false; + } + boolean lower = false; + boolean upper = false; + for (int iter = 2; iter < 42; iter++) { + char c = address.charAt(iter); + if (c >= 'a' && c <= 'f') { + lower = true; + } else if (c >= 'A' && c <= 'F') { + upper = true; + } else if (c < '0' || c > '9') { + return false; + } + } + if (lower && upper) { + return checksum(address).equals(address); + } + return true; + } + + /// `address` lowercased. It must be 0x and 40 hex digits; the checksum is + /// [#valid]'s business, not this one's. + public static String normalize(String address) { + if (address == null || address.length() != 42 || !address.startsWith("0x")) { + throw new IllegalArgumentException("not an address"); + } + StringBuilder out = new StringBuilder(42).append("0x"); + for (int iter = 2; iter < 42; iter++) { + char c = address.charAt(iter); + if (c >= 'A' && c <= 'F') { + c = (char) (c - 'A' + 'a'); + } else if (!(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f')) { + throw new IllegalArgumentException("not an address"); + } + out.append(c); + } + return out.toString(); + } + + private static byte[] ascii(String text) { + byte[] out = new byte[text.length()]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) text.charAt(iter); + } + return out; + } + + /// The 20 bytes of `address`. + public static byte[] bytes(String address) { + return Hex32.decode(normalize(address).substring(2)); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/EthKeys.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/EthKeys.java new file mode 100644 index 00000000000..71e3618622d --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/EthKeys.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +/// The two primitives every chain class here needs, behind one door: +/// Keccak-256 and recoverable secp256k1 signatures. They come from the +/// framework's `com.codename1.security` (constant-time where a secret is +/// involved, with published test vectors in its unit tests); nothing in this +/// package does curve arithmetic of its own. +public final class EthKeys { + private EthKeys() { + } + + public static byte[] keccak256(byte[] data) { + return CoreCrypto.keccak256(data); + } + + /// The 64-byte public key (x || y, no 0x04 prefix) of `privateKey`. + public static byte[] publicKey(byte[] privateKey) { + return CoreCrypto.publicKey64(privateKey); + } + + /// r (32) || s (32, low-S) || recovery id (one byte, 0 or 1). + public static byte[] sign(byte[] privateKey, byte[] hash32) { + return CoreCrypto.signRecoverable(privateKey, hash32); + } + + /// The 64-byte public key that made `signature` over `hash32`. + public static byte[] recover(byte[] hash32, byte[] signature) { + return CoreCrypto.recover64(hash32, signature); + } + + /// `(key + tweak) mod n`, or null when the result is not a valid key + /// (BIP-32 then skips to the next index). + public static byte[] addPrivate(byte[] key, byte[] tweak) { + return CoreCrypto.tweakAdd(key, tweak); + } + + /// The 33-byte compressed public key of `privateKey`. + public static byte[] compressedPublicKey(byte[] privateKey) { + return CoreCrypto.publicKey33(privateKey); + } + + /// True when `key` is a valid private key: 32 bytes, 0 < key < n. + public static boolean validPrivate(byte[] key) { + return CoreCrypto.validPrivate(key); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/EthRpc.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/EthRpc.java new file mode 100644 index 00000000000..9f3128c124b --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/EthRpc.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/// A JSON-RPC client for an Ethereum node: the handful of calls a custodian +/// needs, nothing that would let the node hold a key. +/// +/// Every signature is made here, in the bank, and the node only ever sees +/// finished raw transactions (`eth_sendRawTransaction`). Never point this at +/// a node with unlocked accounts, and never call `eth_sendTransaction` or +/// `personal_*`: the node is a public, untrusted relay. Its answers are +/// untrusted too -- the watchers cross-check what matters (a receipt's block +/// hash against the canonical block at that height, a transaction's `to` and +/// `value` against what was signed). +public class EthRpc { + private final String url; + private final AtomicInteger nextId = new AtomicInteger(1); + + public EthRpc(String url) { + if (!url.startsWith("https://") && !url.startsWith("http://127.0.0.1") + && !url.startsWith("http://localhost")) { + // A node answer decides when deposits are credited: plain HTTP to + // anything but this machine lets the network forge them. + throw new IllegalArgumentException("the node must be reached over HTTPS"); + } + this.url = url; + } + + public long chainId() throws IOException { + return Wei.parseLong((String) call("eth_chainId")); + } + + public long blockNumber() throws IOException { + return Wei.parseLong((String) call("eth_blockNumber")); + } + + /// The next nonce for `address`, counting transactions still pending. + public long pendingNonce(String address) throws IOException { + return Wei.parseLong((String) call("eth_getTransactionCount", address, "pending")); + } + + public byte[] balance(String address) throws IOException { + return Wei.parseQuantity((String) call("eth_getBalance", address, "latest")); + } + + /// The block at `number` with full transactions, or null past the head. + @SuppressWarnings("unchecked") + public Map block(long number) throws IOException { + return (Map) call("eth_getBlockByNumber", Wei.quantity(number), Boolean.TRUE); + } + + /// The receipt of `txHash`, or null while it is not mined. + @SuppressWarnings("unchecked") + public Map receipt(String txHash) throws IOException { + return (Map) call("eth_getTransactionReceipt", txHash); + } + + /// The base fee of the next block, in wei. + @SuppressWarnings("unchecked") + public byte[] nextBaseFee() throws IOException { + Map history = (Map) call("eth_feeHistory", "0x1", "latest", + new ArrayList()); + List fees = (List) history.get("baseFeePerGas"); + return Wei.parseQuantity((String) fees.get(fees.size() - 1)); + } + + /// The node's suggested priority fee, in wei. + public byte[] priorityFee() throws IOException { + return Wei.parseQuantity((String) call("eth_maxPriorityFeePerGas")); + } + + /// Broadcasts a signed transaction and answers its hash. + public String sendRaw(byte[] raw) throws IOException { + return (String) call("eth_sendRawTransaction", "0x" + Hex32.encode(raw)); + } + + private Object call(String method, Object... params) throws IOException { + Map request = new HashMap(); + request.put("jsonrpc", "2.0"); + request.put("method", method); + List list = new ArrayList(); + for (Object p : params) { + list.add(p); + } + request.put("params", list); + int id = nextId.getAndIncrement(); + request.put("id", Integer.valueOf(id)); + Web.Result result = Web.postJson(url, Json.write(request), null); + if (!result.isSuccess()) { + throw new IOException(method + ": the node answered HTTP " + result.getStatus()); + } + Map answer = Json.parseObject(result.getBodyAsString()); + Object error = answer.get("error"); + if (error instanceof Map) { + Object message = ((Map) error).get("message"); + throw new NodeException(method, message == null ? "error" : String.valueOf(message)); + } + return answer.get("result"); + } + + /// The node refused a call; for `eth_sendRawTransaction` its message says + /// why (nonce too low, underpriced, insufficient funds). + public static class NodeException extends IOException { + private static final long serialVersionUID = 1L; + + public NodeException(String method, String message) { + super(method + ": " + message); + } + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Hex32.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Hex32.java new file mode 100644 index 00000000000..785160feb0d --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Hex32.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +/// Lowercase hex, both ways, for the chain's byte strings. +public final class Hex32 { + private static final char[] DIGITS = "0123456789abcdef".toCharArray(); + + private Hex32() { + } + + public static String encode(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + for (int iter = 0; iter < bytes.length; iter++) { + out[iter * 2] = DIGITS[(bytes[iter] >> 4) & 0xf]; + out[iter * 2 + 1] = DIGITS[bytes[iter] & 0xf]; + } + return new String(out); + } + + /// `hex` (no `0x`, even length, either case) as bytes. + public static byte[] decode(String hex) { + if ((hex.length() & 1) == 1) { + throw new IllegalArgumentException("odd hex length"); + } + byte[] out = new byte[hex.length() / 2]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) ((digit(hex.charAt(iter * 2)) << 4) | digit(hex.charAt(iter * 2 + 1))); + } + return out; + } + + /// `hex` with an optional `0x`. + public static byte[] decode0x(String hex) { + return decode(hex.startsWith("0x") || hex.startsWith("0X") ? hex.substring(2) : hex); + } + + private static int digit(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + throw new IllegalArgumentException("not a hex digit: " + c); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Rlp.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Rlp.java new file mode 100644 index 00000000000..3b5febb221d --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Rlp.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +import java.io.ByteArrayOutputStream; +import java.util.List; + +/// Ethereum's Recursive Length Prefix encoding, the byte form every +/// transaction is signed and broadcast in. Only encoding: a node's answers +/// arrive as JSON, so nothing here ever parses RLP from outside. +/// +/// An item is a byte string or a list of items. Integers are byte strings +/// too, big-endian with no leading zero bytes (zero is the empty string), and +/// [#quantity] is the only way a number should get in: a stray leading zero +/// makes a transaction every node refuses. +public final class Rlp { + private Rlp() { + } + + /// One byte string. + public static byte[] string(byte[] value) { + if (value.length == 1 && (value[0] & 0xff) < 0x80) { + return new byte[] {value[0]}; + } + return concat(prefix(0x80, value.length), value); + } + + /// A list of already encoded items. + public static byte[] list(List encodedItems) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + for (byte[] item : encodedItems) { + body.write(item, 0, item.length); + } + byte[] payload = body.toByteArray(); + return concat(prefix(0xc0, payload.length), payload); + } + + /// A non-negative integer. + public static byte[] quantity(long value) { + if (value < 0) { + throw new IllegalArgumentException("negative quantity"); + } + return string(Wei.trim(Wei.ofLong(value))); + } + + /// An unsigned big-endian integer, leading zeros stripped. + public static byte[] quantity(byte[] unsigned) { + return string(Wei.trim(unsigned)); + } + + private static byte[] prefix(int offset, int length) { + if (length < 56) { + return new byte[] {(byte) (offset + length)}; + } + byte[] len = Wei.trim(Wei.ofLong(length)); + byte[] out = new byte[1 + len.length]; + out[0] = (byte) (offset + 55 + len.length); + System.arraycopy(len, 0, out, 1, len.length); + return out; + } + + static byte[] concat(byte[] a, byte[] b) { + byte[] out = new byte[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/SeedSigner.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/SeedSigner.java new file mode 100644 index 00000000000..6e46082d75e --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/SeedSigner.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +/// [ChainSigner] from a BIP-32 seed held in memory. Each key is derived when +/// it is needed and wiped after the one signature it makes, so a heap dump +/// between signatures holds the seed and nothing more -- which is the reason +/// a product moves the seed itself into a key service. +public final class SeedSigner implements ChainSigner { + /// The hot wallet: account 1, so it never collides with a deposit index. + private static final long HOT_ACCOUNT = 1; + + private final byte[] seed; + private final String hot; + + public SeedSigner(byte[] seed) { + this.seed = seed; + this.hot = hotKey().address(); + } + + private Bip32 hotKey() { + return Bip32.master(seed).child(44 + Bip32.HARDENED).child(60 + Bip32.HARDENED) + .child(HOT_ACCOUNT + Bip32.HARDENED).child(0).child(0); + } + + @Override + public String hotAddress() { + return hot; + } + + @Override + public String depositAddress(long index) { + return Bip32.ethereum(seed, index).address(); + } + + @Override + public byte[] signWithHot(byte[] hash32) { + return signWith(hotKey().privateKey(), hash32); + } + + @Override + public byte[] signWithDeposit(long index, byte[] hash32) { + return signWith(Bip32.ethereum(seed, index).privateKey(), hash32); + } + + private static byte[] signWith(byte[] key, byte[] hash32) { + try { + return EthKeys.sign(key, hash32); + } finally { + for (int iter = 0; iter < key.length; iter++) { + key[iter] = 0; + } + } + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Wei.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Wei.java new file mode 100644 index 00000000000..749c9e07887 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/chain/Wei.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +/// Amounts on the chain, which do not fit a `long`. +/// +/// A wei is 10^-18 ETH, so a `long` holds only 9.2 ETH of them, and the +/// server has no `java.math` to reach for. The chain's numbers are therefore +/// kept as unsigned big-endian byte arrays of any length and the ledger's in +/// its own unit, gwei (10^-9 ETH, the `decimals` of 9 the ETH asset row +/// carries). The two meet only here: [#fromGwei] is exact, and [#toGwei] +/// rounds down, so a deposit of 1.0000000005 ETH credits 1 ETH and the dust +/// stays in the wallet -- the ledger never claims a coin the chain does not +/// hold. +public final class Wei { + /// Wei in one gwei. + static final long GWEI = 1000000000L; + + private Wei() { + } + + /// `value` as 8 unsigned big-endian bytes. + public static byte[] ofLong(long value) { + byte[] out = new byte[8]; + for (int iter = 7; iter >= 0; iter--) { + out[iter] = (byte) value; + value >>>= 8; + } + return out; + } + + /// `value` without leading zero bytes; zero is the empty array. + public static byte[] trim(byte[] value) { + int start = 0; + while (start < value.length && value[start] == 0) { + start++; + } + byte[] out = new byte[value.length - start]; + System.arraycopy(value, start, out, 0, out.length); + return out; + } + + /// The wei in `gwei` ledger units, exactly. + public static byte[] fromGwei(long gwei) { + if (gwei < 0) { + throw new IllegalArgumentException("negative amount"); + } + return trim(mulSmall(ofLong(gwei), GWEI)); + } + + /// The whole gwei in `wei`, rounded down. Throws if the amount does not + /// fit the ledger, which no honest deposit reaches (9.2 billion ETH). + public static long toGwei(byte[] wei) { + long[] qr = divSmall(wei, GWEI); + return qr[0]; + } + + /// True when `wei` is not a whole number of gwei. + public static boolean hasDust(byte[] wei) { + return divSmall(wei, GWEI)[1] != 0; + } + + /// `a * m` for a small non-negative `m` (below 2^31 is plenty here). + static byte[] mulSmall(byte[] a, long m) { + byte[] out = new byte[a.length + 8]; + long carry = 0; + for (int iter = a.length - 1, o = out.length - 1; o >= 0; iter--, o--) { + long digit = iter >= 0 ? a[iter] & 0xff : 0; + long product = digit * m + carry; + out[o] = (byte) product; + carry = product >>> 8; + } + return out; + } + + /// `{a / d, a % d}` for `0 < d < 2^31`; the quotient must fit a long. + static long[] divSmall(byte[] a, long d) { + long rem = 0; + long quotient = 0; + for (int iter = 0; iter < a.length; iter++) { + long cur = (rem << 8) | (a[iter] & 0xff); + long q = cur / d; + rem = cur % d; + if ((quotient >>> 55) != 0) { + throw new ArithmeticException("amount too large for the ledger"); + } + quotient = (quotient << 8) | q; + } + if (quotient < 0) { + throw new ArithmeticException("amount too large for the ledger"); + } + return new long[] {quotient, rem}; + } + + /// A JSON-RPC quantity, `0x` and hex digits, as unsigned bytes. + public static byte[] parseQuantity(String hex) { + if (hex == null || !hex.startsWith("0x") || hex.length() < 3) { + throw new IllegalArgumentException("not a quantity: " + hex); + } + String digits = hex.substring(2); + if ((digits.length() & 1) == 1) { + digits = "0" + digits; + } + return Hex32.decode(digits); + } + + /// A JSON-RPC quantity that fits a long (block numbers, nonces, gas). + public static long parseLong(String hex) { + byte[] bytes = trim(parseQuantity(hex)); + if (bytes.length > 7) { + throw new IllegalArgumentException("quantity too large: " + hex); + } + long out = 0; + for (byte b : bytes) { + out = (out << 8) | (b & 0xff); + } + return out; + } + + /// `value` as a JSON-RPC quantity: `0x0`, or `0x` and hex without + /// leading zeros. + public static String quantity(byte[] value) { + String hex = Hex32.encode(trim(value)); + int start = 0; + while (start < hex.length() - 1 && hex.charAt(start) == '0') { + start++; + } + return "0x" + (hex.length() == 0 ? "0" : hex.substring(start)); + } + + public static String quantity(long value) { + return quantity(ofLong(value)); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Asset.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Asset.java new file mode 100644 index 00000000000..dc52690f81d --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Asset.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// Something an account can hold: a currency or a coin. The ledger treats +/// both alike; `decimals` says how many ledger units make one whole unit. +@Entity +public class Asset { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(12)") + private String code = ""; + + @Column(nullable = false, type = "VARCHAR(60)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(8)") + private String kind = ""; + + @Column(nullable = false) + private int decimals; + + @Column(nullable = false) + private boolean enabled; + + public Asset() { + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public int getDecimals() { + return decimals; + } + + public void setDecimals(int decimals) { + this.decimals = decimals; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/AuditEvent.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/AuditEvent.java new file mode 100644 index 00000000000..58cd1570bd9 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/AuditEvent.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One row of the hash-chained audit log. +@Entity +public class AuditEvent { + @Id(autoIncrement = false) + @Column(nullable = false) + private long seq; + + @Column(nullable = false) + private Instant at = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(190)") + private String actor = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String action = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String subject = ""; + + @Column(nullable = false, type = "VARCHAR(500)") + private String detail = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String address = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String prevHash = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String hash = ""; + + public AuditEvent() { + } + + public long getSeq() { + return seq; + } + + public void setSeq(long seq) { + this.seq = seq; + } + + public Instant getAt() { + return at; + } + + public void setAt(Instant at) { + this.at = at; + } + + public String getActor() { + return actor; + } + + public void setActor(String actor) { + this.actor = actor; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public String getDetail() { + return detail; + } + + public void setDetail(String detail) { + this.detail = detail; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getPrevHash() { + return prevHash; + } + + public void setPrevHash(String prevHash) { + this.prevHash = prevHash; + } + + public String getHash() { + return hash; + } + + public void setHash(String hash) { + this.hash = hash; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/AuditHead.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/AuditHead.java new file mode 100644 index 00000000000..0b7b808f99a --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/AuditHead.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// The newest link of the audit chain; one row. +@Entity +public class AuditHead { + @Id(autoIncrement = false) + @Column(nullable = false) + private int id; + + @Column(nullable = false) + private long seq; + + @Column(nullable = false, type = "VARCHAR(64)") + private String hash = ""; + + public AuditHead() { + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public long getSeq() { + return seq; + } + + public void setSeq(long seq) { + this.seq = seq; + } + + public String getHash() { + return hash; + } + + public void setHash(String hash) { + this.hash = hash; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Card.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Card.java new file mode 100644 index 00000000000..8008082f982 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Card.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A virtual card on a checking account. Spending draws on that account. +@Entity +public class Card { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String accountId = ""; + + @Column(nullable = false, type = "VARCHAR(4)") + private String last4 = ""; + + @Column(nullable = false) + private boolean frozen; + + @Column(nullable = false) + private long dailyLimit; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Card() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getAccountId() { + return accountId; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public String getLast4() { + return last4; + } + + public void setLast4(String last4) { + this.last4 = last4; + } + + public boolean isFrozen() { + return frozen; + } + + public void setFrozen(boolean frozen) { + this.frozen = frozen; + } + + public long getDailyLimit() { + return dailyLimit; + } + + public void setDailyLimit(long dailyLimit) { + this.dailyLimit = dailyLimit; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/ChainCursor.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/ChainCursor.java new file mode 100644 index 00000000000..8eba947859b --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/ChainCursor.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// How far the deposit scanner has read a chain: the next block to read, and +/// the hash of the last one read, so a reorganization below the +/// confirmation depth is noticed rather than silently skipped. +@Entity +public class ChainCursor { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(12)") + private String chain = ""; + + @Column(nullable = false) + private long nextBlock; + + @Column(nullable = false, type = "VARCHAR(66)") + private String lastHash = ""; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public ChainCursor() { + } + + public String getChain() { + return chain; + } + + public void setChain(String chain) { + this.chain = chain; + } + + public long getNextBlock() { + return nextBlock; + } + + public void setNextBlock(long nextBlock) { + this.nextBlock = nextBlock; + } + + public String getLastHash() { + return lastHash; + } + + public void setLastHash(String lastHash) { + this.lastHash = lastHash; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/ChainTransfer.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/ChainTransfer.java new file mode 100644 index 00000000000..e83bef6894c --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/ChainTransfer.java @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// Coins crossing between the bank and a blockchain, in either direction. +/// +/// A withdrawal (`OUT`) moves through QUEUED (the ledger has taken the coins +/// from the customer into the in-flight account), SIGNED (a transaction with +/// a fixed nonce exists and `raw` holds it, so a crash re-broadcasts the same +/// bytes rather than signing a second spend), BROADCAST, and then CONFIRMED +/// or FAILED, each final state posting once to the ledger. A deposit (`IN`) +/// is written only when it is already deep enough to credit, CONFIRMED from +/// birth; `(txHash, logIndex)` is unique, so a rescan cannot credit it twice. +@Entity +public class ChainTransfer { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + /// `IN` or `OUT`. + @Column(nullable = false, type = "VARCHAR(3)") + private String direction = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(12)") + private String asset = ""; + + /// The outside address: where an OUT pays, or the deposit address an IN arrived at. + @Column(nullable = false, type = "VARCHAR(42)") + private String address = ""; + + /// Ledger units (gwei for ETH). + @Column(nullable = false) + private long amount; + + @Column(nullable = false, type = "VARCHAR(12)") + private String status = ""; + + /// Empty until signed. + @Column(nullable = false, type = "VARCHAR(66)") + private String txHash = ""; + + /// 0 for a plain value transfer; reserved for token transfers, which arrive as logs. + @Column(nullable = false) + private int logIndex; + + /// The hot wallet nonce an OUT was signed with, or -1. + @Column(nullable = false) + private long nonce; + + /// The signed transaction, hex, so a re-broadcast is the same bytes. + @Column(nullable = false, type = "VARCHAR(4000)") + private String raw = ""; + + @Column(nullable = false) + private long blockNumber; + + @Column(nullable = false, type = "VARCHAR(66)") + private String blockHash = ""; + + /// Network fee paid, ledger units, rounded up. + @Column(nullable = false) + private long fee; + + /// The CryptoAddress an OUT pays, or the DepositAddress an IN reached. + @Column(nullable = false, type = "VARCHAR(32)") + private String reference = ""; + + /// Why it failed, for the customer and the back office. + @Column(nullable = false, type = "VARCHAR(200)") + private String detail = ""; + + @Column(nullable = false) + private Instant broadcastAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public ChainTransfer() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getDirection() { + return direction; + } + + public void setDirection(String direction) { + this.direction = direction; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getAsset() { + return asset; + } + + public void setAsset(String asset) { + this.asset = asset; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public long getAmount() { + return amount; + } + + public void setAmount(long amount) { + this.amount = amount; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getTxHash() { + return txHash; + } + + public void setTxHash(String txHash) { + this.txHash = txHash; + } + + public int getLogIndex() { + return logIndex; + } + + public void setLogIndex(int logIndex) { + this.logIndex = logIndex; + } + + public long getNonce() { + return nonce; + } + + public void setNonce(long nonce) { + this.nonce = nonce; + } + + public String getRaw() { + return raw; + } + + public void setRaw(String raw) { + this.raw = raw; + } + + public long getBlockNumber() { + return blockNumber; + } + + public void setBlockNumber(long blockNumber) { + this.blockNumber = blockNumber; + } + + public String getBlockHash() { + return blockHash; + } + + public void setBlockHash(String blockHash) { + this.blockHash = blockHash; + } + + public long getFee() { + return fee; + } + + public void setFee(long fee) { + this.fee = fee; + } + + public String getReference() { + return reference; + } + + public void setReference(String reference) { + this.reference = reference; + } + + public String getDetail() { + return detail; + } + + public void setDetail(String detail) { + this.detail = detail; + } + + public Instant getBroadcastAt() { + return broadcastAt; + } + + public void setBroadcastAt(Instant broadcastAt) { + this.broadcastAt = broadcastAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/CryptoAddress.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/CryptoAddress.java new file mode 100644 index 00000000000..db2b5bf0233 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/CryptoAddress.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// An address outside the bank that a customer may withdraw a crypto asset to. +/// Usable only from `usableAt`: the cooling-off that gives the customer a day +/// to notice an address they did not add. +@Entity +public class CryptoAddress { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(12)") + private String asset = ""; + + @Column(nullable = false, type = "VARCHAR(100)") + private String address = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String label = ""; + + @Column(nullable = false) + private Instant usableAt = Instant.EPOCH; + + @Column(nullable = false) + private boolean removed; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public CryptoAddress() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getAsset() { + return asset; + } + + public void setAsset(String asset) { + this.asset = asset; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public Instant getUsableAt() { + return usableAt; + } + + public void setUsableAt(Instant usableAt) { + this.usableAt = usableAt; + } + + public boolean isRemoved() { + return removed; + } + + public void setRemoved(boolean removed) { + this.removed = removed; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/DepositAddress.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/DepositAddress.java new file mode 100644 index 00000000000..ddf258c9ae8 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/DepositAddress.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A customer's own address on a chain, derived from the bank's deposit seed +/// at BIP-44 path m/44'/60'/0'/0/`hdIndex`, so a standard wallet can recover +/// every one of them from the seed alone. Stored, not re-derived on every +/// scan: the scanner only needs to know whose an address is. +@Entity +public class DepositAddress { + /// Lowercase, 0x-prefixed. + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(42)") + private String address = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(12)") + private String asset = ""; + + @Column(nullable = false) + private long hdIndex; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public DepositAddress() { + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getAsset() { + return asset; + } + + public void setAsset(String asset) { + this.asset = asset; + } + + public long getHdIndex() { + return hdIndex; + } + + public void setHdIndex(long hdIndex) { + this.hdIndex = hdIndex; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Device.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Device.java new file mode 100644 index 00000000000..95e945053b3 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Device.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A device's request-signing key and what it said about itself when it +/// registered. +@Entity +public class Device { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(400)") + private String publicKey = ""; + + @Column(nullable = false, type = "VARCHAR(20)") + private String platform = ""; + + @Column(nullable = false) + private boolean rooted; + + @Column(nullable = false) + private boolean attested; + + @Column(nullable = false) + private boolean revoked; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Device() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getPublicKey() { + return publicKey; + } + + public void setPublicKey(String publicKey) { + this.publicKey = publicKey; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public boolean isRooted() { + return rooted; + } + + public void setRooted(boolean rooted) { + this.rooted = rooted; + } + + public boolean isAttested() { + return attested; + } + + public void setAttested(boolean attested) { + this.attested = attested; + } + + public boolean isRevoked() { + return revoked; + } + + public void setRevoked(boolean revoked) { + this.revoked = revoked; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/JournalEntry.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/JournalEntry.java new file mode 100644 index 00000000000..df8a90a33f9 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/JournalEntry.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One movement of value; its postings sum to zero in every asset. +@Entity +public class JournalEntry { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String idempotencyKey = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String requestHash = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String initiator = ""; + + @Column(nullable = false, type = "VARCHAR(140)") + private String memo = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public JournalEntry() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getRequestHash() { + return requestHash; + } + + public void setRequestHash(String requestHash) { + this.requestHash = requestHash; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getInitiator() { + return initiator; + } + + public void setInitiator(String initiator) { + this.initiator = initiator; + } + + public String getMemo() { + return memo; + } + + public void setMemo(String memo) { + this.memo = memo; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/LedgerAccount.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/LedgerAccount.java new file mode 100644 index 00000000000..463e8bb955c --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/LedgerAccount.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A balance in one asset, owned by a customer or by the bank itself. +@Entity +public class LedgerAccount { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(12)") + private String asset = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String name = ""; + + @Column(nullable = false) + private long balance; + + @Column(nullable = false) + private boolean frozen; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public LedgerAccount() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getAsset() { + return asset; + } + + public void setAsset(String asset) { + this.asset = asset; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public long getBalance() { + return balance; + } + + public void setBalance(long balance) { + this.balance = balance; + } + + public boolean isFrozen() { + return frozen; + } + + public void setFrozen(boolean frozen) { + this.frozen = frozen; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/LoginFailure.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/LoginFailure.java new file mode 100644 index 00000000000..90ce30a42bf --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/LoginFailure.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// Wrong passwords counted against one account, for the lockout in +/// [com.codenameone.examples.bastion.account.SignInGuard]. Kept in the +/// database and not in memory so that every instance of the server counts the +/// same attempts: spreading guesses over instances buys an attacker nothing. +@Entity +public class LoginFailure { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private int failures; + + @Column(nullable = false) + private Instant firstAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant lockedUntil = Instant.EPOCH; + + public LoginFailure() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public int getFailures() { + return failures; + } + + public void setFailures(int failures) { + this.failures = failures; + } + + public Instant getFirstAt() { + return firstAt; + } + + public void setFirstAt(Instant firstAt) { + this.firstAt = firstAt; + } + + public Instant getLockedUntil() { + return lockedUntil; + } + + public void setLockedUntil(Instant lockedUntil) { + this.lockedUntil = lockedUntil; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Market.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Market.java new file mode 100644 index 00000000000..6ddfae66b1c --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Market.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A pair customers trade on the exchange: `base` priced in `quote`. +/// +/// The row is also the market's lock. Placing or cancelling an order holds +/// it first ([com.codenameone.examples.bastion.repository.MarketRepository#hold]), +/// so one market matches one order at a time, and `seq` is the time half of +/// price-time priority: a counter, not a clock, so two orders placed in the +/// same millisecond still have an order. +@Entity +public class Market { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(16)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(12)") + private String base = ""; + + @Column(nullable = false, type = "VARCHAR(12)") + private String quote = ""; + + /// The smallest quantity of `base` an order may name, in its ledger units. + /// Every quantity is a whole number of lots. + @Column(nullable = false) + private long lot; + + /// The simulated reference price, in `quote` ledger units per one whole + /// `base` unit: what limits are valued at. Not the last trade, which two + /// colluding customers could print at any price they like. + @Column(nullable = false) + private long indexPrice; + + @Column(nullable = false) + private long lastPrice; + + @Column(nullable = false) + private long seq; + + public Market() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getBase() { + return base; + } + + public void setBase(String base) { + this.base = base; + } + + public String getQuote() { + return quote; + } + + public void setQuote(String quote) { + this.quote = quote; + } + + public long getLot() { + return lot; + } + + public void setLot(long lot) { + this.lot = lot; + } + + public long getIndexPrice() { + return indexPrice; + } + + public void setIndexPrice(long indexPrice) { + this.indexPrice = indexPrice; + } + + public long getLastPrice() { + return lastPrice; + } + + public void setLastPrice(long lastPrice) { + this.lastPrice = lastPrice; + } + + public long getSeq() { + return seq; + } + + public void setSeq(long seq) { + this.seq = seq; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Payee.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Payee.java new file mode 100644 index 00000000000..2e64b4d5f02 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Payee.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// Someone a customer may pay: another customer, by username. +@Entity +public class Payee { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String nickname = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + /// Kept rather than deleted; see `V9__payee_removed.sql`. + @Column(nullable = false) + private boolean removed; + + public Payee() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public boolean isRemoved() { + return removed; + } + + public void setRemoved(boolean removed) { + this.removed = removed; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Posting.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Posting.java new file mode 100644 index 00000000000..0a6b2ee1242 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Posting.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One leg of a journal entry: positive adds to the account, negative takes. +@Entity +public class Posting { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String entryId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String accountId = ""; + + @Column(nullable = false, type = "VARCHAR(12)") + private String asset = ""; + + @Column(nullable = false) + private long amount; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Posting() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getEntryId() { + return entryId; + } + + public void setEntryId(String entryId) { + this.entryId = entryId; + } + + public String getAccountId() { + return accountId; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public String getAsset() { + return asset; + } + + public void setAsset(String asset) { + this.asset = asset; + } + + public long getAmount() { + return amount; + } + + public void setAmount(long amount) { + this.amount = amount; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Profile.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Profile.java new file mode 100644 index 00000000000..c1b66579d2e --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Profile.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// What the bank knows of a customer beyond their sign-in, which lives in the +/// security layer's own `cn1_users` table and is not this application's to +/// add columns to. +@Entity +public class Profile { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String displayName = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Profile() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Trade.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Trade.java new file mode 100644 index 00000000000..7a60582a966 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Trade.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One match between a buy and a sell order: one journal entry, `entryId`. +@Entity +public class Trade { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String market = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String buyOrder = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String sellOrder = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String buyer = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String seller = ""; + + @Column(nullable = false, type = "VARCHAR(4)") + private String takerSide = ""; + + @Column(nullable = false) + private long price; + + @Column(nullable = false) + private long quantity; + + /// Quote units out of the buyer's hold, fee included. + @Column(nullable = false) + private long buyerPaid; + + /// Quote units into the seller's account, fee taken. + @Column(nullable = false) + private long sellerGot; + + @Column(nullable = false) + private long fee; + + @Column(nullable = false, type = "VARCHAR(32)") + private String entryId = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Trade() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getMarket() { + return market; + } + + public void setMarket(String market) { + this.market = market; + } + + public String getBuyOrder() { + return buyOrder; + } + + public void setBuyOrder(String buyOrder) { + this.buyOrder = buyOrder; + } + + public String getSellOrder() { + return sellOrder; + } + + public void setSellOrder(String sellOrder) { + this.sellOrder = sellOrder; + } + + public String getBuyer() { + return buyer; + } + + public void setBuyer(String buyer) { + this.buyer = buyer; + } + + public String getSeller() { + return seller; + } + + public void setSeller(String seller) { + this.seller = seller; + } + + public String getTakerSide() { + return takerSide; + } + + public void setTakerSide(String takerSide) { + this.takerSide = takerSide; + } + + public long getPrice() { + return price; + } + + public void setPrice(long price) { + this.price = price; + } + + public long getQuantity() { + return quantity; + } + + public void setQuantity(long quantity) { + this.quantity = quantity; + } + + public long getBuyerPaid() { + return buyerPaid; + } + + public void setBuyerPaid(long buyerPaid) { + this.buyerPaid = buyerPaid; + } + + public long getSellerGot() { + return sellerGot; + } + + public void setSellerGot(long sellerGot) { + this.sellerGot = sellerGot; + } + + public long getFee() { + return fee; + } + + public void setFee(long fee) { + this.fee = fee; + } + + public String getEntryId() { + return entryId; + } + + public void setEntryId(String entryId) { + this.entryId = entryId; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/TradeOrder.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/TradeOrder.java new file mode 100644 index 00000000000..559899fdaa0 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/TradeOrder.java @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One order on the exchange. Prices are in quote ledger units (cents) per +/// one whole base unit; quantities are in base ledger units (satoshi). +/// +/// `notional` is the running sum of price times quantity over the fills, the +/// exact value before any division, from which `settled` -- the quote paid or +/// received so far -- is derived by rounding once, in the bank's favour. `held` +/// is what is still in the owner's HOLD account on this order's behalf. +@Entity +public class TradeOrder { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String market = ""; + + @Column(nullable = false, type = "VARCHAR(4)") + private String side = ""; + + @Column(nullable = false, type = "VARCHAR(6)") + private String type = ""; + + /// The limit; 0 for a market order. + @Column(nullable = false) + private long price; + + /// Base units asked for; 0 for a market buy, which names `budget`. + @Column(nullable = false) + private long quantity; + + /// A market buy's quote amount, fee included. + @Column(nullable = false) + private long budget; + + @Column(nullable = false) + private long filled; + + @Column(nullable = false) + private long notional; + + @Column(nullable = false) + private long settled; + + @Column(nullable = false) + private long fee; + + @Column(nullable = false) + private long held; + + @Column(nullable = false, type = "VARCHAR(10)") + private String status = ""; + + @Column(nullable = false) + private long seq; + + @Column(nullable = false, type = "VARCHAR(80)") + private String clientKey = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public TradeOrder() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getMarket() { + return market; + } + + public void setMarket(String market) { + this.market = market; + } + + public String getSide() { + return side; + } + + public void setSide(String side) { + this.side = side; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public long getPrice() { + return price; + } + + public void setPrice(long price) { + this.price = price; + } + + public long getQuantity() { + return quantity; + } + + public void setQuantity(long quantity) { + this.quantity = quantity; + } + + public long getBudget() { + return budget; + } + + public void setBudget(long budget) { + this.budget = budget; + } + + public long getFilled() { + return filled; + } + + public void setFilled(long filled) { + this.filled = filled; + } + + public long getNotional() { + return notional; + } + + public void setNotional(long notional) { + this.notional = notional; + } + + public long getSettled() { + return settled; + } + + public void setSettled(long settled) { + this.settled = settled; + } + + public long getFee() { + return fee; + } + + public void setFee(long fee) { + this.fee = fee; + } + + public long getHeld() { + return held; + } + + public void setHeld(long held) { + this.held = held; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public long getSeq() { + return seq; + } + + public void setSeq(long seq) { + this.seq = seq; + } + + public String getClientKey() { + return clientKey; + } + + public void setClientKey(String clientKey) { + this.clientKey = clientKey; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Transfer.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Transfer.java new file mode 100644 index 00000000000..a15814272a7 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Transfer.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One movement of money as the customer sees it; its id is the journal +/// entry's. +@Entity +public class Transfer { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String fromAccount = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String toAccount = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String via = ""; + + @Column(nullable = false, type = "VARCHAR(12)") + private String asset = ""; + + @Column(nullable = false) + private long amount; + + @Column(nullable = false) + private boolean outgoing; + + @Column(nullable = false, type = "VARCHAR(140)") + private String memo = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Transfer() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getFromAccount() { + return fromAccount; + } + + public void setFromAccount(String fromAccount) { + this.fromAccount = fromAccount; + } + + public String getToAccount() { + return toAccount; + } + + public void setToAccount(String toAccount) { + this.toAccount = toAccount; + } + + public String getVia() { + return via; + } + + public void setVia(String via) { + this.via = via; + } + + public String getAsset() { + return asset; + } + + public void setAsset(String asset) { + this.asset = asset; + } + + public long getAmount() { + return amount; + } + + public void setAmount(long amount) { + this.amount = amount; + } + + public boolean isOutgoing() { + return outgoing; + } + + public void setOutgoing(boolean outgoing) { + this.outgoing = outgoing; + } + + public String getMemo() { + return memo; + } + + public void setMemo(String memo) { + this.memo = memo; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/UsedNonce.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/UsedNonce.java new file mode 100644 index 00000000000..0ec25777c1e --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/UsedNonce.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.domain; + +import java.time.Instant; +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A request signature's nonce, kept until the signature has expired anyway. +@Entity +public class UsedNonce { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false) + private Instant expiresAt = Instant.EPOCH; + + public UsedNonce() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/CryptoWallets.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/CryptoWallets.java new file mode 100644 index 00000000000..560c78b0288 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/CryptoWallets.java @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.exchange; + +import java.time.Instant; +import com.codenameone.examples.bastion.repository.ProfileRepository; +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.api.CryptoAddressDto; +import com.codenameone.examples.bastion.api.NewCryptoAddressDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.Asset; +import com.codenameone.examples.bastion.domain.CryptoAddress; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codenameone.examples.bastion.repository.CryptoAddressRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The addresses outside the bank a customer's coins may be withdrawn to. +/// +/// An allow-list, not a field on the withdrawal: whoever has stolen a token +/// and a device key can type their own address into a withdrawal in a +/// second, but adding it here starts a cooling-off period +/// (`bastion.address.cooloff-hours`, a day by default) before it can be used, +/// and the customer is told of every address added (the audit, and the app's +/// security centre). Removing one is immediate and needs no signature: +/// taking a permission away is never the dangerous direction. +/// +/// The format check is deliberately loose -- letters and digits, 20 to 100 of +/// them -- because the chain is simulated. A real exchange validates each +/// chain's checksum (base58check, bech32, EIP-55) so a typo is refused before +/// coins go to an address nobody holds. +@Component +public class CryptoWallets { + static final int MAX_ADDRESSES = 20; + + private final CryptoAddressRepository addresses; + private final ProfileRepository profiles; + private final Ledger ledger; + private final Audit audit; + private final long coolOffMillis; + private final int perDay; + + public CryptoWallets(CryptoAddressRepository addresses, ProfileRepository profiles, + Ledger ledger, Audit audit, + @Value("${bastion.address.cooloff-hours:24}") String coolOffHours, + @Value("${bastion.address.per-day:5}") String perDay) { + this.perDay = Integer.parseInt(perDay.trim()); + this.addresses = addresses; + this.profiles = profiles; + this.ledger = ledger; + this.audit = audit; + this.coolOffMillis = Long.parseLong(coolOffHours.trim()) * 3600L * 1000L; + } + + @Transactional(rollbackFor = Exception.class) + public CryptoAddressDto add(String owner, NewCryptoAddressDto request) throws IOException { + if (request == null || request.address() == null) { + throw Refused.with(400, "send-address", "Send the address"); + } + Asset asset = ledger.asset(request.asset()); + if ("FIAT".equals(asset.getKind())) { + throw Refused.with(400, "coin-withdrawn-address", "Only a coin is withdrawn to an address"); + } + String address = request.address().trim(); + if (!wellFormed(address)) { + throw Refused.with(400, "not-address", "That is not an address"); + } + if ("ETH".equals(asset.getCode())) { + // An Ethereum address carries its own checksum in its letter case + // (EIP-55): a mistyped mixed-case address is refused here rather + // than sent to an address nobody holds. Stored in the canonical + // checksummed form so the duplicate check below compares like with like. + if (!com.codenameone.examples.bastion.chain.EthAddress.valid(address)) { + throw Refused.with(400, "not-address", "That is not an address"); + } + address = com.codenameone.examples.bastion.chain.EthAddress.normalize(address); + } + // Behind the customer's profile row: two of these at once would each + // count the rows before the other inserted and together pass the cap. + profiles.hold(owner); + int live = 0; + // New addresses per day, removed ones counted: a cap on live ones + // alone lets a thief add, withdraw after cooling-off, remove and add + // again as fast as the API answers. + int today = 0; + long since = System.currentTimeMillis() - 24L * 3600000L; + for (CryptoAddress a : addresses.findByOwnerOrderByCreatedAtAsc(owner)) { + today += a.getCreatedAt().toEpochMilli() > since ? 1 : 0; + if (!a.isRemoved()) { + live++; + if (a.getAsset().equals(asset.getCode()) && a.getAddress().equals(address)) { + throw Refused.with(409, "address-already-list", "That address is already on your list"); + } + } + } + if (live >= MAX_ADDRESSES) { + throw Refused.with(409, "remove-address-before-adding-another", "Remove an address before adding another"); + } + if (today >= perDay) { + throw Refused.with(429, "too-many-new-addresses", "You have added as many addresses as a day allows; try again tomorrow"); + } + long now = System.currentTimeMillis(); + CryptoAddress a = new CryptoAddress(); + a.setId(Ids.next()); + a.setOwner(owner); + a.setAsset(asset.getCode()); + a.setAddress(address); + String label = request.label() == null ? "" : request.label(); + a.setLabel(label.length() > 80 ? label.substring(0, 80) : label); + a.setUsableAt(Instant.ofEpochMilli(now + coolOffMillis)); + a.setCreatedAt(Instant.ofEpochMilli(now)); + addresses.insert(a); + audit.append(owner, "ADDRESS_ADDED", a.getId(), asset.getCode() + " " + address + + " usable at " + a.getUsableAt()); + return dto(a); + } + + @Transactional(rollbackFor = Exception.class) + public CryptoAddressDto remove(String owner, String id) throws IOException { + CryptoAddress a = owned(owner, id); + a.setRemoved(true); + addresses.save(a); + audit.append(owner, "ADDRESS_REMOVED", a.getId(), a.getAsset() + " " + a.getAddress()); + return dto(a); + } + + @Transactional(readOnly = true) + public List list(String owner) throws IOException { + List out = new ArrayList(); + for (CryptoAddress a : addresses.findByOwnerOrderByCreatedAtAsc(owner)) { + if (!a.isRemoved()) { + out.add(dto(a)); + } + } + return out; + } + + /// `owner`'s live address `id`, or 404 -- whether it does not exist, was + /// removed, or is someone else's. + @Transactional(readOnly = true) + public CryptoAddress owned(String owner, String id) throws IOException { + CryptoAddress a = id == null ? null : addresses.findById(id); + if (a == null || a.isRemoved() || !a.getOwner().equals(owner)) { + throw Refused.with(404, "no-such-address", "No such address"); + } + return a; + } + + static boolean wellFormed(String address) { + if (address.length() < 20 || address.length() > 100) { + return false; + } + for (int i = 0; i < address.length(); i++) { + char c = address.charAt(i); + boolean ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + if (!ok) { + return false; + } + } + return true; + } + + static CryptoAddressDto dto(CryptoAddress a) { + return new CryptoAddressDto(a.getId(), a.getAsset(), a.getAddress(), a.getLabel(), + a.getUsableAt().toEpochMilli(), a.getCreatedAt().toEpochMilli()); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/Exchange.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/Exchange.java new file mode 100644 index 00000000000..be17cb79a7f --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/Exchange.java @@ -0,0 +1,629 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.exchange; + +import java.time.Instant; +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.api.NewOrderDto; +import com.codenameone.examples.bastion.api.OrderDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.Asset; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.domain.Market; +import com.codenameone.examples.bastion.domain.Trade; +import com.codenameone.examples.bastion.domain.TradeOrder; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codenameone.examples.bastion.ledger.Leg; +import com.codenameone.examples.bastion.money.Money; +import com.codenameone.examples.bastion.repository.MarketRepository; +import com.codenameone.examples.bastion.repository.TradeOrderRepository; +import com.codenameone.examples.bastion.repository.TradeRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The matching engine: orders in, trades out, every one of them a balanced +/// entry in the same [Ledger] the bank's accounts live in. +/// +/// **Holds.** An order commits its funds before it can match. A buy moves the +/// most it can cost -- `ceil(limit * quantity / 10^decimals)` plus the most +/// fee that could be charged on it -- from the owner's CHECKING account in the +/// quote asset into their HOLD account; a sell moves the coins from the WALLET +/// into HOLD. A trade then pays out of the holds, so a customer can never +/// trade with money a payment spent a moment ago, and two orders cannot both +/// spend the same dollar: the ledger's conditional update decides. +/// +/// **Matching** runs inside the placing transaction, behind the market's row +/// ([MarketRepository#hold]), so one market matches one order at a time and +/// the book a match reads is the book it changes. Price-time priority: the +/// best price first, and at one price the order with the lower `seq`, a +/// counter on the market row. A trade happens at the *resting* order's price, +/// so an aggressive order gets any better price the book offers and the +/// unspent part of its hold is released when it finishes. +/// +/// **Each trade is one journal entry across both assets**: +/// +/// ``` +/// buyer HOLD(quote) -paid (+fee, when the buyer is the taker) +/// seller CHECKING(quote) +got (-fee, when the seller is the taker) +/// SYS_FEES(quote) +fee + (paid - got) +/// seller HOLD(base) -quantity +/// buyer WALLET(base) +quantity +/// ``` +/// +/// **Rounding, in the bank's favour and once per order.** `price * quantity` +/// is rarely a whole number of cents. Each order keeps the exact running sum +/// of `price * quantity` over its fills (`notional`), and the quote it has +/// paid or received so far is that sum divided once: rounded *up* for the +/// buyer, *down* for the seller. A fill moves the difference between the +/// rounded totals before and after it, so an order filled in a hundred pieces +/// pays exactly what it would have paid in one, never a hundred roundings. +/// Whatever the two sides' roundings leave between them goes to SYS_FEES; it +/// is at most a cent per order. The taker fee is rounded up the same way, on +/// the taker's running total. +/// +/// **Self-trade prevention.** An order never trades with its own owner: the +/// older, resting order is cancelled and its hold released. Without it a +/// customer could print any price they like on an empty market for free, and +/// wash-trade volume. +/// +/// **Market orders** never rest. A market buy names the quote it may spend, +/// fee included; it holds exactly that, buys whole lots while the next lot +/// is affordable, and releases the rest -- so it never leaves a hold behind. +/// +/// Every order, cancel and trade is audited, and the audit events are written +/// last, after every balance: the transaction then takes its locks in the +/// order a payment does (balances, then audit_head) and cannot deadlock one. +@Component +public class Exchange { + public static final String BUY = "BUY"; + public static final String SELL = "SELL"; + public static final String LIMIT = "LIMIT"; + public static final String MARKET = "MARKET"; + public static final String OPEN = "OPEN"; + public static final String FILLED = "FILLED"; + public static final String CANCELLED = "CANCELLED"; + + /// A bound on lots in one order, so `price * quantity` stays far inside a + /// `long` for any price a market here will see. + static final long MAX_LOTS = 1000000000L; + + private final Ledger ledger; + private final Audit audit; + private final Markets markets; + private final MarketRepository marketRows; + private final TradeOrderRepository orders; + private final TradeRepository trades; + private final long feeBps; + private final long collarBps; + private final Money money; + + public Exchange(Ledger ledger, Audit audit, Markets markets, MarketRepository marketRows, + TradeOrderRepository orders, TradeRepository trades, Money money, + @Value("${bastion.exchange.taker-fee-bps:20}") String feeBps, + @Value("${bastion.exchange.collar-bps:1000}") String collarBps) { + this.money = money; + // How far from the index price an order may trade, in basis points: + // 1000 is 10%. A limit price outside it is refused, and a market + // order stops walking the book at its edge -- so a thief with a + // signed-in session who sells into their own resting order at a + // silly price, or a fat finger, loses at most this much a fill. + this.collarBps = Long.parseLong(collarBps.trim()); + if (this.collarBps <= 0 || this.collarBps >= 10000) { + throw new IllegalArgumentException("bastion.exchange.collar-bps must be 1 to 9999"); + } + this.ledger = ledger; + this.audit = audit; + this.markets = markets; + this.marketRows = marketRows; + this.orders = orders; + this.trades = trades; + // Basis points of the quote a taker pays: 20 is 0.2%. Makers pay none, + // which is what pays a market maker to keep a book. + this.feeBps = Long.parseLong(feeBps.trim()); + if (this.feeBps < 0 || this.feeBps >= 10000) { + throw new IllegalArgumentException("bastion.exchange.taker-fee-bps must be 0 to 9999"); + } + } + + /// Places `request` for `owner`, outside the customer limits: the market + /// maker's own quotes, and the tests of the engine itself. + public OrderDto place(String owner, String key, NewOrderDto request) throws IOException { + return place(owner, key, request, false); + } + + /// Places `request` for `owner`. A retry with the same key answers the + /// order its first attempt placed. With `limited`, a customer's order: + /// its worst-case worth in USD is held to the per-payment and daily + /// limits ([Money#checkTrade]) along with the day's payments and fills. + public OrderDto place(String owner, String key, NewOrderDto request, boolean limited) + throws IOException { + if (key == null || key.length() < 16 || key.length() > 80) { + throw Refused.with(400, "send-idempotency-key-16-80", "Send an Idempotency-Key of 16 to 80 characters"); + } + if (request == null) { + throw Refused.with(400, "send-order", "Send the order"); + } + try { + return placeOnce(owner, key, request, limited); + } catch (ResponseStatusException answered) { + throw answered; + } catch (IOException | RuntimeException failed) { + // Two copies of one order at once: the second lost on the + // (owner, client_key) index and rolled back whole. Asked again it + // finds the first. + if (existing(owner, key) != null) { + return placeOnce(owner, key, request, limited); + } + throw failed; + } + } + + @Transactional(readOnly = true) + private TradeOrder existing(String owner, String key) throws IOException { + return orders.findFirstByOwnerAndClientKey(owner, key); + } + + // Private and called from inside the class, and still transactional: + // the backend weaver moves the body behind the annotated method itself + // (no proxy), so a self-call and a private method get the aspect too. + @Transactional(rollbackFor = Exception.class) + private OrderDto placeOnce(String owner, String key, NewOrderDto r, boolean limited) + throws IOException { + TradeOrder earlier = orders.findFirstByOwnerAndClientKey(owner, key); + if (earlier != null) { + if (!earlier.getMarket().equals(r.market()) || !earlier.getSide().equals(r.side()) + || !earlier.getType().equals(r.type()) || earlier.getPrice() != r.price() + || earlier.getQuantity() != r.quantity() || earlier.getBudget() != r.budget()) { + throw Refused.with(422, "idempotency-key-reused-order", "That Idempotency-Key was already used for a different order"); + } + return dto(earlier); + } + Market m = lock(r.market()); + boolean buy = BUY.equals(r.side()); + if (!buy && !SELL.equals(r.side())) { + throw Refused.with(400, "order-buy-sell", "An order is a BUY or a SELL"); + } + boolean limit = LIMIT.equals(r.type()); + if (!limit && !MARKET.equals(r.type())) { + throw Refused.with(400, "order-limit-market", "An order is LIMIT or MARKET"); + } + Asset base = ledger.asset(m.getBase()); + long scale = Markets.scale(base); + long held; + if (limit || !buy) { + if (r.quantity() <= 0 || r.quantity() % m.getLot() != 0 + || r.quantity() / m.getLot() > MAX_LOTS) { + throw Refused.with(400, "quantity-whole-number-unit-lots", "The quantity must be a whole number of {0}-unit lots", m.getLot()); + } + if (limit && r.price() <= 0) { + throw Refused.with(400, "limit-order-needs-price", "A limit order needs a price"); + } + if (limit && (r.price() < low(m) || r.price() > high(m))) { + throw Refused.with(400, "price-outside-collar", + "The price must be within {0}% of the index price", collarBps / 100); + } + if (!limit && (r.price() != 0 || r.budget() != 0)) { + throw Refused.with(400, "market-sell-names-quantity", "A market sell names only a quantity"); + } + if (limit && r.budget() != 0) { + throw Refused.with(400, "limit-order-names-no-budget", "A limit order names no budget"); + } + if (buy) { + long most = Markets.ceilDiv(exact(r.price(), r.quantity()), scale); + held = Markets.addExact(most, fee(most)); + } else { + held = r.quantity(); + } + } else { + if (r.budget() <= 0 || r.price() != 0 || r.quantity() != 0) { + throw Refused.with(400, "market-buy-names-budget", "A market buy names only a budget"); + } + held = r.budget(); + } + if (limited) { + // The most the order can carry away, in USD: what a buy holds + // (its quote, fee included), or a sell's coins at the index. + money.holdCustomer(owner); + money.checkTrade(owner, buy ? money.usdValue(m.getQuote(), held) + : money.usdValue(m.getBase(), held), openValue(owner)); + } + + long now = System.currentTimeMillis(); + marketRows.advance(m.getId()); + TradeOrder o = new TradeOrder(); + o.setId(Ids.next()); + o.setOwner(owner); + o.setMarket(m.getId()); + o.setSide(r.side()); + o.setType(r.type()); + o.setPrice(r.price()); + o.setQuantity(r.quantity()); + o.setBudget(r.budget()); + o.setHeld(held); + o.setStatus(OPEN); + // The value read behind the lock, plus the one just added: a re-read + // would answer the session's cached row, not the update. + o.setSeq(m.getSeq() + 1); + o.setClientKey(key); + o.setCreatedAt(Instant.ofEpochMilli(now)); + o.setUpdatedAt(Instant.ofEpochMilli(now)); + // Inserted before anything moves: a concurrent copy of the request + // fails on the key index here. + orders.insert(o); + + String asset = buy ? m.getQuote() : m.getBase(); + LedgerAccount source = funding(owner, asset, buy); + LedgerAccount hold = ensure(owner, asset, AccountKinds.HOLD); + // Insufficient funds is a 409 from the ledger, and rolls the order + // back with it: an order that cannot be paid for was never placed. + ledger.post("hold:" + o.getId(), "ORDER_HOLD", owner, o.getMarket() + " " + o.getSide(), + legs(source.getId(), -held, hold.getId(), held)); + + List events = new ArrayList(); + events.add(new String[] {owner, "ORDER_PLACED", o.getId(), m.getId() + " " + o.getType() + + " " + o.getSide() + " " + o.getQuantity() + " @ " + o.getPrice() + + (o.getBudget() > 0 ? " budget " + o.getBudget() : "") + " held " + held}); + match(m, base, o, events); + if (!limit || o.getFilled() == o.getQuantity()) { + finish(m, o, events); + } + o.setUpdatedAt(Instant.now()); + orders.save(o); + record(events); + return dto(o); + } + + /// Cancels `owner`'s open order `id` and releases what it still holds. + @Transactional(rollbackFor = Exception.class) + public OrderDto cancel(String owner, String id) throws IOException { + String market = id == null ? null : orders.marketOf(id, owner); + if (market == null) { + throw Refused.with(404, "no-such-order", "No such order"); + } + Market m = lock(market); + // The first read of the row, and behind the lock: it may have filled + // while we waited. Loading it before the lock would keep that stale + // copy in the session (PostgreSQL and MySQL let a fill commit in + // between; SQLite serializes the writers and hid it). + TradeOrder o = orders.findById(id); + if (!OPEN.equals(o.getStatus())) { + throw Refused.with(409, "order-already-status", "That order is already {0}", o.getStatus()); + } + List events = new ArrayList(); + finish(m, o, events); + o.setUpdatedAt(Instant.now()); + orders.save(o); + record(events); + return dto(o); + } + + /// Order `id`, whoever's it is; null when there is none. For the admin + /// side and the tests, never answered to a customer as it is. + @Transactional(readOnly = true) + public TradeOrder order(String id) throws IOException { + return id == null ? null : orders.findById(id); + } + + /// `owner`'s open orders, newest first. + @Transactional(readOnly = true) + public List open(String owner) throws IOException { + return orders.findByOwnerAndStatusOrderByCreatedAtDesc(owner, OPEN); + } + + /// What `owner`'s open orders on `side` hold in all: what their HOLD + /// account in that side's asset must contain. + @Transactional(readOnly = true) + public long held(String owner, String side) throws IOException { + Long sum = orders.held(owner, OPEN, side); + return sum == null ? 0L : sum.longValue(); + } + + /// Walks the other side of the book while `taker` still crosses it. + private void match(Market m, Asset base, TradeOrder taker, List events) + throws IOException { + boolean buy = BUY.equals(taker.getSide()); + boolean limit = LIMIT.equals(taker.getType()); + // A market order walks only as far as the collar: its worst case is + // the edge, as a limit order's is its price. + List book = buy + ? orders.asks(m.getId(), SELL, OPEN, limit ? taker.getPrice() : high(m)) + : orders.bids(m.getId(), BUY, OPEN, limit ? taker.getPrice() : low(m)); + long scale = Markets.scale(base); + for (TradeOrder resting : book) { + if (taker.getQuantity() > 0 && taker.getFilled() == taker.getQuantity()) { + break; + } + if (resting.getOwner().equals(taker.getOwner())) { + events.add(new String[] {resting.getOwner(), "SELF_TRADE_PREVENTED", + resting.getId(), "cancelled by " + taker.getId()}); + finish(m, resting, events); + resting.setUpdatedAt(Instant.now()); + orders.save(resting); + continue; + } + long available = resting.getQuantity() - resting.getFilled(); + long quantity; + if (taker.getQuantity() > 0) { + quantity = Math.min(available, taker.getQuantity() - taker.getFilled()); + } else { + quantity = affordable(taker, resting.getPrice(), available, m.getLot(), scale); + if (quantity == 0) { + // Not one more lot at this price, so none at a worse one. + break; + } + } + TradeOrder buyOrder = buy ? taker : resting; + TradeOrder sellOrder = buy ? resting : taker; + trade(m, scale, buyOrder, sellOrder, resting.getPrice(), quantity, taker, events); + if (resting.getFilled() == resting.getQuantity()) { + finish(m, resting, events); + } + resting.setUpdatedAt(Instant.now()); + orders.save(resting); + } + } + + /// The most whole lots, up to `available`, a market buy can still pay + /// for at `price`, fee included. A binary search: cost only grows with + /// quantity. + private long affordable(TradeOrder o, long price, long available, long lot, long scale) { + long low = 0; + long high = available / lot; + while (low < high) { + long mid = low + (high - low + 1) / 2; + if (cost(o, price, mid * lot, scale) <= o.getBudget()) { + low = mid; + } else { + high = mid - 1; + } + } + return low * lot; + } + + /// What a taker buy has paid in all, fee included, once `quantity` more + /// at `price` is added; [Long#MAX_VALUE] when that does not fit a long. + private long cost(TradeOrder o, long price, long quantity, long scale) { + try { + long quote = Markets.ceilDiv(Markets.addExact(o.getNotional(), + Markets.multiplyExact(price, quantity)), scale); + return Markets.addExact(quote, fee(quote)); + } catch (ArithmeticException tooLarge) { + return Long.MAX_VALUE; + } + } + + /// One fill: one journal entry. See the class comment for the legs and + /// the rounding. + private void trade(Market m, long scale, TradeOrder buy, TradeOrder sell, long price, + long quantity, TradeOrder taker, List events) throws IOException { + long product = exact(price, quantity); + long buyNotional = Markets.addExact(buy.getNotional(), product); + long sellNotional = Markets.addExact(sell.getNotional(), product); + long paidTotal = Markets.ceilDiv(buyNotional, scale); + long gotTotal = sellNotional / scale; + long paid = paidTotal - buy.getSettled(); + long got = gotTotal - sell.getSettled(); + // The taker's fee on its running total. A taker fills only while it + // is being placed, before it rests, so its running total is all taker + // volume; once resting it is a maker and pays nothing more. + boolean buyerTakes = taker == buy; + long feeNow = fee(buyerTakes ? paidTotal : gotTotal) - taker.getFee(); + long buyerOut = paid + (buyerTakes ? feeNow : 0L); + long sellerIn = got - (buyerTakes ? 0L : feeNow); + long toFees = buyerOut - sellerIn; + + LedgerAccount buyerHold = ensure(buy.getOwner(), m.getQuote(), AccountKinds.HOLD); + LedgerAccount sellerHold = ensure(sell.getOwner(), m.getBase(), AccountKinds.HOLD); + LedgerAccount sellerCash = ensure(sell.getOwner(), m.getQuote(), AccountKinds.CHECKING); + LedgerAccount buyerWallet = ensure(buy.getOwner(), m.getBase(), AccountKinds.WALLET); + LedgerAccount fees = ledger.system(m.getQuote(), AccountKinds.SYS_FEES); + Map sums = new LinkedHashMap(); + add(sums, buyerHold.getId(), -buyerOut); + add(sums, sellerCash.getId(), sellerIn); + add(sums, fees.getId(), toFees); + add(sums, sellerHold.getId(), -quantity); + add(sums, buyerWallet.getId(), quantity); + List legs = new ArrayList(); + for (Map.Entry e : sums.entrySet()) { + // A leg of zero is refused by the ledger, and a cent of rounding + // can make the fee leg, or both quote legs, exactly that. + if (e.getValue().longValue() != 0L) { + legs.add(new Leg(e.getKey(), e.getValue().longValue())); + } + } + String id = Ids.next(); + String entry = ledger.post("trade:" + id, "TRADE", taker.getOwner(), + m.getId() + " " + quantity + " @ " + price, legs).getId(); + + buy.setFilled(buy.getFilled() + quantity); + buy.setNotional(buyNotional); + buy.setSettled(paidTotal); + buy.setHeld(buy.getHeld() - buyerOut); + sell.setFilled(sell.getFilled() + quantity); + sell.setNotional(sellNotional); + sell.setSettled(gotTotal); + sell.setHeld(sell.getHeld() - quantity); + taker.setFee(taker.getFee() + feeNow); + if (buy.getHeld() < 0 || sell.getHeld() < 0) { + // The hold is computed to cover the worst case; this would be a + // bug in that computation, and the ledger would already have + // refused the entry had the HOLD account itself run dry. + throw new IllegalStateException("An order spent more than it held"); + } + + Trade t = new Trade(); + t.setId(id); + t.setMarket(m.getId()); + t.setBuyOrder(buy.getId()); + t.setSellOrder(sell.getId()); + t.setBuyer(buy.getOwner()); + t.setSeller(sell.getOwner()); + t.setTakerSide(taker.getSide()); + t.setPrice(price); + t.setQuantity(quantity); + t.setBuyerPaid(buyerOut); + t.setSellerGot(sellerIn); + t.setFee(feeNow); + t.setEntryId(entry); + t.setCreatedAt(Instant.now()); + trades.insert(t); + marketRows.traded(m.getId(), price); + String detail = m.getId() + " " + quantity + " @ " + price + " buy " + buy.getId() + + " sell " + sell.getId() + " fee " + feeNow; + events.add(new String[] {buy.getOwner(), "TRADE", id, detail}); + events.add(new String[] {sell.getOwner(), "TRADE", id, detail}); + } + + /// Ends an order -- filled, cancelled, or a market order done -- and + /// moves whatever it still holds back where it came from. + private void finish(Market m, TradeOrder o, List events) throws IOException { + boolean buy = BUY.equals(o.getSide()); + boolean complete = o.getQuantity() > 0 ? o.getFilled() == o.getQuantity() + : o.getFilled() > 0; + boolean cancelled = OPEN.equals(o.getStatus()) && LIMIT.equals(o.getType()) && !complete; + if (o.getHeld() > 0) { + String asset = buy ? m.getQuote() : m.getBase(); + LedgerAccount hold = ensure(o.getOwner(), asset, AccountKinds.HOLD); + LedgerAccount back = funding(o.getOwner(), asset, buy); + ledger.post("release:" + o.getId(), "ORDER_RELEASE", o.getOwner(), o.getMarket(), + legs(hold.getId(), -o.getHeld(), back.getId(), o.getHeld())); + o.setHeld(0); + } + o.setStatus(complete ? FILLED : CANCELLED); + if (cancelled || (!complete && MARKET.equals(o.getType()))) { + events.add(new String[] {o.getOwner(), "ORDER_CANCELLED", o.getId(), + o.getMarket() + " filled " + o.getFilled() + " of " + o.getQuantity()}); + } + } + + /// What `owner`'s open orders hold, in USD: each one's hold valued in its + /// own asset (a buy's quote, a sell's coins). The whole hold, even of a + /// partly filled order, so it can only over-count. + private long openValue(String owner) throws IOException { + long sum = 0; + for (TradeOrder o : orders.findByOwnerAndStatusOrderByCreatedAtDesc(owner, OPEN)) { + Market om = markets.get(o.getMarket()); + long v = money.usdValue(BUY.equals(o.getSide()) ? om.getQuote() : om.getBase(), + o.getHeld()); + sum = sum + v < sum ? Long.MAX_VALUE : sum + v; + } + return sum; + } + + /// The lowest price the collar lets `m` trade at. + private long low(Market m) { + return Math.max(1L, m.getIndexPrice() - band(m)); + } + + /// The highest price the collar lets `m` trade at. + private long high(Market m) { + return Markets.addExact(m.getIndexPrice(), band(m)); + } + + /// The collar's half-width around the market's index price. The index is + /// set once, when [Markets] creates the market, and nothing that fills + /// moves it: the collar must never follow fills, or a run of trades at + /// its edge would walk it -- and the price -- as far as anyone liked. + private long band(Market m) { + return Markets.multiplyExact(m.getIndexPrice(), collarBps) / 10000L; + } + + private Market lock(String id) throws IOException { + if (id == null || marketRows.hold(id) != 1) { + throw Refused.with(404, "no-such-market", "No such market"); + } + return markets.get(id); + } + + /// Where an order's funds come from and return to: the owner's first + /// CHECKING account in a fiat asset, their first WALLET in a coin. + private LedgerAccount funding(String owner, String asset, boolean fiatSide) + throws IOException { + String kind = fiatSide ? AccountKinds.CHECKING : AccountKinds.WALLET; + LedgerAccount found = first(owner, asset, kind); + if (found == null) { + throw fiatSide + ? Refused.with(409, "open-checking-first", "Open a {0} checking account first", asset) + : Refused.with(409, "open-wallet-first", "Open a {0} wallet first", asset); + } + return found; + } + + private LedgerAccount ensure(String owner, String asset, String kind) throws IOException { + LedgerAccount found = first(owner, asset, kind); + return found != null ? found : ledger.open(owner, asset, kind, kind); + } + + private LedgerAccount first(String owner, String asset, String kind) throws IOException { + for (LedgerAccount a : ledger.accountsOf(owner)) { + if (a.getAsset().equals(asset) && a.getKind().equals(kind)) { + return a; + } + } + return null; + } + + private void record(List events) throws IOException { + for (String[] e : events) { + audit.append(e[0], e[1], e[2], e[3]); + } + } + + /// The taker fee on `quote`, rounded up. + long fee(long quote) { + return Markets.ceilDiv(Markets.multiplyExact(quote, feeBps), 10000L); + } + + private static long exact(long a, long b) { + try { + return Markets.multiplyExact(a, b); + } catch (ArithmeticException tooLarge) { + throw Refused.with(400, "order-too-large", "That order is too large"); + } + } + + private static void add(Map sums, String account, long amount) { + Long was = sums.get(account); + sums.put(account, Long.valueOf(Markets.addExact(was == null ? 0L : was.longValue(), + amount))); + } + + private static List legs(String a, long amountA, String b, long amountB) { + List legs = new ArrayList(); + legs.add(new Leg(a, amountA)); + legs.add(new Leg(b, amountB)); + return legs; + } + + public static OrderDto dto(TradeOrder o) { + return new OrderDto(o.getId(), o.getMarket(), o.getSide(), o.getType(), o.getPrice(), + o.getQuantity(), o.getBudget(), o.getFilled(), o.getSettled(), o.getFee(), + o.getStatus(), o.getCreatedAt().toEpochMilli()); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/ExchangeEndpoint.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/ExchangeEndpoint.java new file mode 100644 index 00000000000..43df96df19e --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/ExchangeEndpoint.java @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.exchange; + +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.bastion.api.BookDto; +import com.codenameone.examples.bastion.api.CryptoAddressDto; +import com.codenameone.examples.bastion.api.CryptoWithdrawalDto; +import com.codenameone.examples.bastion.api.ExchangeApiServer; +import com.codenameone.examples.bastion.api.LevelDto; +import com.codenameone.examples.bastion.api.MarketDto; +import com.codenameone.examples.bastion.api.NewCryptoAddressDto; +import com.codenameone.examples.bastion.api.NewOrderDto; +import com.codenameone.examples.bastion.api.OrderDto; +import com.codenameone.examples.bastion.api.ReceiptDto; +import com.codenameone.examples.bastion.api.TickerDto; +import com.codenameone.examples.bastion.api.TradeDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.chain.Chain; +import com.codenameone.examples.bastion.chain.EthAddress; +import com.codenameone.examples.bastion.domain.ChainTransfer; +import com.codenameone.examples.bastion.api.ChainTransferDto; +import com.codenameone.examples.bastion.api.DepositAddressDto; +import com.codenameone.examples.bastion.domain.CryptoAddress; +import com.codenameone.examples.bastion.domain.Device; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.domain.Market; +import com.codenameone.examples.bastion.domain.Trade; +import com.codenameone.examples.bastion.domain.TradeOrder; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codenameone.examples.bastion.money.Devices; +import com.codenameone.examples.bastion.money.Money; +import com.codenameone.examples.bastion.money.RequestSigning; +import com.codenameone.examples.bastion.repository.TradeOrderRepository; +import com.codenameone.examples.bastion.repository.TradeRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/// [com.codenameone.examples.bastion.api.ExchangeApi], served. +/// +/// The markets, books and tickers are public to any signed-in caller and +/// name nobody: a book is quantities at prices, never an order id or an +/// owner. Orders, trades, addresses and withdrawals are the caller's own, +/// looked up through them, so another customer's id answers 404. +@Component +public class ExchangeEndpoint implements ExchangeApiServer { + /// Price levels shown on each side of a book. + static final int DEPTH = 20; + + private final Markets markets; + private final Exchange exchange; + private final CryptoWallets wallets; + private final Ledger ledger; + private final Money money; + private final RequestSigning signing; + private final Audit audit; + private final TradeOrderRepository orders; + private final TradeRepository trades; + + private final Chain chain; + + public ExchangeEndpoint(Markets markets, Exchange exchange, CryptoWallets wallets, + Ledger ledger, Money money, RequestSigning signing, Audit audit, + TradeOrderRepository orders, TradeRepository trades, Chain chain) { + this.chain = chain; + this.markets = markets; + this.exchange = exchange; + this.wallets = wallets; + this.ledger = ledger; + this.money = money; + this.signing = signing; + this.audit = audit; + this.orders = orders; + this.trades = trades; + } + + @Override + public List markets() throws Exception { + List out = new ArrayList(); + for (Market m : markets.all()) { + out.add(new MarketDto(m.getId(), m.getBase(), m.getQuote(), m.getLot(), + m.getIndexPrice(), m.getLastPrice())); + } + return out; + } + + @Override + public BookDto book(String market) throws Exception { + Market m = markets.get(market); + List open = openOrders(m.getId()); + return new BookDto(m.getId(), levels(open, Exchange.BUY), levels(open, Exchange.SELL)); + } + + @Override + public TickerDto ticker(String market) throws Exception { + Market m = markets.get(market); + List open = openOrders(m.getId()); + List bids = levels(open, Exchange.BUY); + List asks = levels(open, Exchange.SELL); + return new TickerDto(m.getId(), m.getIndexPrice(), m.getLastPrice(), + bids.isEmpty() ? 0L : bids.get(0).price(), asks.isEmpty() ? 0L : asks.get(0).price(), + System.currentTimeMillis()); + } + + @Transactional(readOnly = true) + private List openOrders(String market) throws IOException { + return orders.findByMarketAndStatus(market, Exchange.OPEN); + } + + /// One side of a book: open quantity summed per price, best first. + static List levels(List open, final String side) { + List mine = new ArrayList(); + for (TradeOrder o : open) { + if (o.getSide().equals(side)) { + mine.add(o); + } + } + final boolean bids = Exchange.BUY.equals(side); + Collections.sort(mine, new Comparator() { + @Override + public int compare(TradeOrder a, TradeOrder b) { + int byPrice = Long.compare(a.getPrice(), b.getPrice()); + return bids ? -byPrice : byPrice; + } + }); + List out = new ArrayList(); + long price = -1; + long quantity = 0; + int count = 0; + for (TradeOrder o : mine) { + if (o.getPrice() != price) { + if (count > 0) { + out.add(new LevelDto(price, quantity, count)); + if (out.size() == DEPTH) { + return out; + } + } + price = o.getPrice(); + quantity = 0; + count = 0; + } + quantity += o.getQuantity() - o.getFilled(); + count++; + } + if (count > 0) { + out.add(new LevelDto(price, quantity, count)); + } + return out; + } + + @Override + public OrderDto place(String idempotencyKey, String signature, NewOrderDto order) + throws Exception { + // Signed by a trusted device, as a payment is: a thief holding only + // the token could otherwise sell the account's coins into their own + // resting order, or buy from it at their price. The engine then holds + // the price to a collar around the index and the order's worth to the + // daily limit ([Exchange#place(String, String, NewOrderDto, boolean)]). + String owner = Devices.customer(); + Device device = signing.require(owner); + money.requireTrusted(device); + return exchange.place(owner, idempotencyKey, order, true); + } + + @Override + public OrderDto cancel(String id) throws Exception { + return exchange.cancel(Devices.customer(), id); + } + + @Override + public List openOrders() throws Exception { + String owner = Devices.customer(); + List out = new ArrayList(); + for (TradeOrder o : mine(owner)) { + out.add(Exchange.dto(o)); + } + return out; + } + + @Transactional(readOnly = true) + private List mine(String owner) throws IOException { + return orders.findByOwnerAndStatusOrderByCreatedAtDesc(owner, Exchange.OPEN); + } + + @Override + public List trades() throws Exception { + String owner = Devices.customer(); + List out = new ArrayList(); + for (Trade t : tradesOf(owner)) { + boolean bought = t.getBuyer().equals(owner); + String side = bought ? Exchange.BUY : Exchange.SELL; + boolean taker = side.equals(t.getTakerSide()); + out.add(new TradeDto(t.getId(), t.getMarket(), side, taker, t.getPrice(), + t.getQuantity(), bought ? t.getBuyerPaid() : t.getSellerGot(), + taker ? t.getFee() : 0L, t.getCreatedAt().toEpochMilli())); + } + return out; + } + + @Transactional(readOnly = true) + private List tradesOf(String owner) throws IOException { + return trades.of(owner); + } + + @Override + public List addresses() throws Exception { + return wallets.list(Devices.customer()); + } + + @Override + public CryptoAddressDto addAddress(String signature, NewCryptoAddressDto address) + throws Exception { + String owner = Devices.customer(); + Device device = signing.require(owner); + money.requireTrusted(device); + return wallets.add(owner, address); + } + + @Override + public CryptoAddressDto removeAddress(String id, String signature) throws Exception { + // Signed, like removing a payee: a token alone must not edit the + // list a customer checks a withdrawal against. + String owner = Devices.customer(); + signing.require(owner); + return wallets.remove(owner, id); + } + + /// Coins out of the bank: always signed by a trusted device, only to an + /// allow-listed address of the same coin past its cooling-off, and held + /// to the crypto limits ([Money]). A refusal is audited here, outside the + /// movement's transaction, which would roll the record back with it. + @Override + public ReceiptDto withdrawCrypto(String id, String idempotencyKey, String signature, + CryptoWithdrawalDto withdrawal) throws Exception { + String owner = Devices.customer(); + LedgerAccount from = ledger.movable(owner, id); + if (!AccountKinds.WALLET.equals(from.getKind())) { + throw Refused.with(400, "coins-withdrawn-wallet", "Coins are withdrawn from a wallet"); + } + Device device = signing.require(owner); + money.requireTrusted(device); + if (withdrawal == null) { + throw Refused.with(400, "send-address-amount", "Send the address and the amount"); + } + CryptoAddress address; + try { + address = wallets.owned(owner, withdrawal.addressId()); + } catch (ResponseStatusException unknown) { + audit.append(owner, "CRYPTO_OUT_REFUSED", from.getId(), "address not allow-listed"); + throw Refused.with(403, "withdraw-address-list", "Withdraw only to an address on your list"); + } + if (!address.getAsset().equals(from.getAsset())) { + throw Refused.with(400, "address-wrong-asset", "That address is for {0}", address.getAsset()); + } + if (System.currentTimeMillis() < address.getUsableAt().toEpochMilli()) { + audit.append(owner, "CRYPTO_OUT_REFUSED", address.getId(), "cooling off until " + + address.getUsableAt()); + throw Refused.with(409, "address-new-used", "That address is new: it can be used from {0}", address.getUsableAt()); + } + try { + return money.withdrawCrypto(owner, idempotencyKey, from, address, + withdrawal.amount()); + } catch (ResponseStatusException refused) { + if (refused.getStatus() == 409) { + audit.append(owner, "CRYPTO_OUT_REFUSED", address.getId(), refused.getReason()); + } + throw refused; + } + } + + /// A customer's own deposit address. Read-only and harmless to show, but + /// only to its owner: it links on-chain activity to a person. + @Override + public DepositAddressDto depositAddress(String asset) throws Exception { + String owner = Devices.customer(); + if (!chain.handles(asset)) { + return new DepositAddressDto(false, asset, "", "", 0, 0); + } + String address = chain.depositAddress(owner).getAddress(); + return new DepositAddressDto(true, asset, EthAddress.checksum(address), chain.network(), + chain.chainId(), chain.confirmations()); + } + + @Override + public List chainTransfers() throws Exception { + String owner = Devices.customer(); + List out = new java.util.ArrayList(); + for (ChainTransfer t : chain.history(owner)) { + out.add(new ChainTransferDto(t.getId(), t.getDirection(), t.getAsset(), t.getAddress(), + t.getAmount(), t.getStatus(), t.getTxHash().startsWith("0x") ? t.getTxHash() : "", + t.getBlockNumber(), t.getFee(), t.getDetail(), t.getCreatedAt().toEpochMilli())); + } + return out; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/MarketMaker.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/MarketMaker.java new file mode 100644 index 00000000000..8d0e466a539 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/MarketMaker.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.exchange; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.account.DemoAccounts; +import com.codenameone.examples.bastion.chain.Chain; +import com.codenameone.examples.bastion.api.NewOrderDto; +import com.codenameone.examples.bastion.domain.Asset; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.domain.Market; +import com.codenameone.examples.bastion.domain.TradeOrder; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codenameone.examples.bastion.ledger.Leg; +import com.codenameone.examples.bastion.repository.TradeOrderRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/// A simulated price feed and market maker, so a development server's +/// exchange has prices and a book to trade against. +/// +/// Runs only on a development profile with `bastion.market-maker.enabled` +/// (true by default; the tests turn it off so their books are their own). +/// Every period it moves each market's index price a random step of up to +/// 0.3%, cancels its quotes and posts new ones: a ladder of bids below the +/// index and asks above it. It trades from `@market-maker`, an owner that is +/// not a username, through the same [Exchange] customers use -- it gets no +/// shortcut, its orders are held and audited like anyone's -- funded once +/// from SYS_CASH. +/// +/// A production exchange gets its prices from real markets and its liquidity +/// from real market makers under contract; none of this ships. +@Component +public class MarketMaker { + /// The maker's owner. A username is an e-mail address, so no customer + /// can sign in as it. + public static final String OWNER = "@market-maker"; + static final int LEVELS = 5; + + private final Exchange exchange; + private final Markets markets; + private final Ledger ledger; + private final TradeOrderRepository orders; + private final boolean enabled; + // A price walk for a demo book, not a secret: java.util.Random, which a + // compiled server's class library has (java.security does not exist there). + private final Random random = new Random(); + + /// `demo` is taken only so that it is built, and its history seeded, + /// before this starts: that history funds the maker at its first day, + /// and a float posted again today would be a day's volume of a million. + private final Chain chain; + + public MarketMaker(Exchange exchange, Markets markets, Ledger ledger, + TradeOrderRepository orders, Config config, DemoAccounts demo, Chain chain, + @Value("${bastion.market-maker.enabled:true}") String enabled) { + this.exchange = exchange; + this.chain = chain; + this.markets = markets; + this.ledger = ledger; + this.orders = orders; + this.enabled = config.isDevelopmentProfile() && "true".equalsIgnoreCase(enabled.trim()); + } + + @PostConstruct + public void start() throws IOException { + if (!enabled) { + return; + } + markets.seed(); + for (Market m : markets.all()) { + fund(m.getQuote(), AccountKinds.CHECKING); + if (!chain.handles(m.getBase())) { + // A real coin is never minted, not even for the maker: it + // quotes only what it bought. + fund(m.getBase(), AccountKinds.WALLET); + } + } + quote(); + } + + @Scheduled(fixedRateString = "${bastion.market-maker.period-ms:15000}") + public void tick() throws IOException { + if (!enabled) { + return; + } + for (Market m : markets.all()) { + long step = m.getIndexPrice() * (random.nextInt(61) - 30) / 10000L; + markets.index(m.getId(), m.getIndexPrice() + step); + } + quote(); + } + + /// Replaces the maker's quotes on every market around its index price. + public void quote() throws IOException { + for (TradeOrder o : open()) { + exchange.cancel(OWNER, o.getId()); + } + for (Market m : markets.all()) { + Asset base = ledger.asset(m.getBase()); + long scale = Markets.scale(base); + // About 50.00 of quote at each level, in whole lots. + long lots = Math.max(1L, 5000L * scale / Math.max(1L, m.getIndexPrice()) / m.getLot()); + for (int level = 1; level <= LEVELS; level++) { + long spread = m.getIndexPrice() * (10L * level) / 10000L; + place(m, Exchange.BUY, m.getIndexPrice() - spread, lots * m.getLot() * level); + place(m, Exchange.SELL, m.getIndexPrice() + spread, lots * m.getLot() * level); + } + } + } + + private void place(Market m, String side, long price, long quantity) throws IOException { + if (price <= 0) { + return; + } + try { + exchange.place(OWNER, "maker-" + Ids.next(), new NewOrderDto(m.getId(), side, + Exchange.LIMIT, price, quantity, 0L)); + } catch (RuntimeException spent) { + // Out of one side's money after customers bought or sold into it: + // the book is thinner until the next deposit, not broken. + System.err.println("bastion: market maker did not quote " + m.getId() + " " + side + + ": " + spent.getMessage()); + } + } + + @Transactional(readOnly = true) + private List open() throws IOException { + return new ArrayList( + orders.findByOwnerAndStatusOrderByCreatedAtDesc(OWNER, Exchange.OPEN)); + } + + /// One deposit per asset, ever: the fixed idempotency key makes a restart + /// find the first. + @Transactional(rollbackFor = Exception.class) + private void fund(String asset, String kind) throws IOException { + LedgerAccount account = null; + for (LedgerAccount a : ledger.accountsOf(OWNER)) { + if (a.getAsset().equals(asset) && a.getKind().equals(kind)) { + account = a; + } + } + if (account == null) { + account = ledger.open(OWNER, asset, kind, "market maker"); + } else if (account.getBalance() > 0) { + // Funded already: by the demo history, or by an earlier start. + return; + } + Asset a = ledger.asset(asset); + long amount = "FIAT".equals(a.getKind()) ? 100000000L : 100L * Markets.scale(a); + LedgerAccount cash = ledger.system(asset, AccountKinds.SYS_CASH); + List legs = new ArrayList(); + legs.add(new Leg(cash.getId(), -amount)); + legs.add(new Leg(account.getId(), amount)); + ledger.post("market-maker-fund-" + asset, "DEPOSIT", OWNER, "market maker float", legs); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/Markets.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/Markets.java new file mode 100644 index 00000000000..43f6b92b7ea --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/Markets.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.exchange; + +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.bastion.domain.Asset; +import com.codenameone.examples.bastion.domain.Market; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codenameone.examples.bastion.repository.MarketRepository; + +import java.io.IOException; +import java.util.List; + +/// The markets, their prices, and what a quantity of an asset is worth. +/// +/// Markets come from `bastion.markets`, a comma-separated list of +/// `id:base:quote:lot:indexPrice`, and are written to the `market` table the +/// first time the server sees them; a later change to the lot or the price of +/// an existing market is ignored, because orders already on the book were +/// placed against them. +/// +/// **Integer arithmetic only.** A price is quote ledger units per one whole +/// base unit (cents per bitcoin); a quantity is base ledger units (satoshi). +/// The value of a quantity is `price * quantity / 10^decimals(base)`, computed +/// with [#multiplyExact(long,long)], so a product too large for a `long` is refused +/// rather than wrapped into a small, plausible number. +@Component +public class Markets { + static final String DEFAULTS = "BTC-USD:BTC:USD:1000:6000000,ETH-USD:ETH:USD:100000:300000"; + + private final MarketRepository markets; + private final Ledger ledger; + private final String configured; + + public Markets(MarketRepository markets, Ledger ledger, + @Value("${bastion.markets:}") String configured) { + this.markets = markets; + this.ledger = ledger; + this.configured = configured.trim().length() == 0 ? DEFAULTS : configured.trim(); + } + + @PostConstruct + public void seed() throws IOException { + for (String one : split(configured, ',')) { + String[] f = split(one.trim(), ':'); + if (f.length != 5) { + throw new IllegalArgumentException("bastion.markets: '" + one + + "' is not id:base:quote:lot:indexPrice"); + } + try { + create(f[0], f[1], f[2], Long.parseLong(f[3]), Long.parseLong(f[4])); + } catch (IOException | RuntimeException failed) { + // Two servers started together both find no row and both + // insert it; the loser's insert fails on the key and changed + // nothing. + if (find(f[0]) == null) { + throw failed; + } + } + } + } + + @Transactional(rollbackFor = Exception.class) + private void create(String id, String base, String quote, long lot, long index) + throws IOException { + if (markets.findById(id) != null) { + return; + } + if (lot <= 0 || index <= 0) { + throw new IllegalArgumentException("bastion.markets: " + id + + " needs a positive lot and price"); + } + ledger.asset(base); + ledger.asset(quote); + Market m = new Market(); + m.setId(id); + m.setBase(base); + m.setQuote(quote); + m.setLot(lot); + m.setIndexPrice(index); + markets.insert(m); + } + + @Transactional(readOnly = true) + public Market find(String id) throws IOException { + return id == null ? null : markets.findById(id); + } + + /// Market `id`, or 404. + @Transactional(readOnly = true) + public Market get(String id) throws IOException { + Market m = find(id); + if (m == null) { + throw Refused.with(404, "no-such-market", "No such market"); + } + return m; + } + + @Transactional(readOnly = true) + public List all() throws IOException { + return markets.findAll(); + } + + /// Moves a market's index price; the simulated feed. + @Transactional(rollbackFor = Exception.class) + public void index(String id, long price) throws IOException { + if (price > 0) { + markets.index(id, price); + } + } + + /// Whether [#value] can price `asset` in `quote`. Asked first where an + /// unpriced asset must not fail the caller's transaction: [#value]'s + /// refusal marks it rollback-only even when it is caught. + @Transactional(readOnly = true) + public boolean hasPrice(String asset, String quote) throws IOException { + return asset.equals(quote) + || !markets.findByBaseAndQuoteOrderByIdAsc(asset, quote).isEmpty(); + } + + /// What `amount` ledger units of `asset` are worth in `quote` ledger + /// units at the index price, **rounded up**: a limit valued here may be + /// reached a cent early, never passed a cent late. An asset with no + /// market in `quote` is refused, since its value is unknown. + @Transactional(readOnly = true) + public long value(String asset, long amount, String quote) throws IOException { + if (asset.equals(quote) || amount == 0) { + return amount; + } + List found = markets.findByBaseAndQuoteOrderByIdAsc(asset, quote); + if (found.isEmpty()) { + throw Refused.with(409, "there-no-price", "There is no price for {0}", asset); + } + // The shortest id is the configured "BTC-USD" rather than any other + // market on the same pair. + Market m = found.get(0); + for (Market other : found) { + if (other.getId().length() < m.getId().length()) { + m = other; + } + } + Asset base = ledger.asset(asset); + try { + return ceilDiv(multiplyExact(m.getIndexPrice(), amount), scale(base)); + } catch (ArithmeticException tooLarge) { + return Long.MAX_VALUE; + } + } + + /// 10 to the asset's decimals: the ledger units in one whole unit. + public static long scale(Asset asset) { + long s = 1; + for (int i = 0; i < asset.getDecimals(); i++) { + s = multiplyExact(s, 10L); + } + return s; + } + + /// `a + b`, refused when it does not fit a `long`. Written out, as is + /// [#multiplyExact(long,long)], because the class library a compiled + /// server links has no `Math.addExact`. + static long addExact(long a, long b) { + long r = a + b; + if (((a ^ r) & (b ^ r)) < 0) { + throw new ArithmeticException("long overflow"); + } + return r; + } + + /// `a * b`, refused when it does not fit a `long`. + static long multiplyExact(long a, long b) { + long r = a * b; + long ax = Math.abs(a); + long ay = Math.abs(b); + if (((ax | ay) >>> 31) != 0) { + if ((b != 0 && r / b != a) || (a == Long.MIN_VALUE && b == -1)) { + throw new ArithmeticException("long overflow"); + } + } + return r; + } + + /// `text` cut at every `separator`, empty pieces kept (String.split is + /// not in a compiled server's class library either). + static String[] split(String text, char separator) { + java.util.List parts = new java.util.ArrayList(); + int from = 0; + for (int i = 0; i < text.length(); i++) { + if (text.charAt(i) == separator) { + parts.add(text.substring(from, i)); + from = i + 1; + } + } + parts.add(text.substring(from)); + return parts.toArray(new String[parts.size()]); + } + + /// `a / b` rounded up, for `a >= 0` and `b > 0`. + static long ceilDiv(long a, long b) { + long q = a / b; + return q * b == a ? q : q + 1; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/AccountKinds.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/AccountKinds.java new file mode 100644 index 00000000000..3a346c948e5 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/AccountKinds.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ledger; + +/// What an account is for. The `SYS_` kinds belong to the bank, are owned by +/// [#BANK], and are the only ones allowed below zero. +public final class AccountKinds { + /// The owner of every account the bank keeps for itself. Not a valid + /// username, so no customer can ever be it. + public static final String BANK = "@bank"; + + public static final String CHECKING = "CHECKING"; + public static final String SAVINGS = "SAVINGS"; + /// A customer's balance in a crypto asset. + public static final String WALLET = "WALLET"; + /// Funds a customer has committed to an open order and can no longer + /// spend; see the exchange. + public static final String HOLD = "HOLD"; + + /// The world outside the ledger: money deposited comes out of it, money + /// withdrawn goes into it. + public static final String SYS_CASH = "SYS_CASH"; + public static final String SYS_FEES = "SYS_FEES"; + /// The blockchain, simulated: coins withdrawn to an outside address go + /// here, as fiat withdrawn goes to SYS_CASH. + public static final String SYS_CHAIN = "SYS_CHAIN"; + /// Coins taken from a customer for a withdrawal whose transaction is not + /// final yet: confirmed, they move on to SYS_CHAIN; failed, back to the + /// customer. Never anything else, so its balance is the coins in flight. + public static final String SYS_CHAIN_TRANSIT = "SYS_CHAIN_TRANSIT"; + /// What the bank pays the network to move coins (gas): withdrawals and + /// the sweeps that gather deposits into the hot wallet. + public static final String SYS_NETWORK_FEES = "SYS_NETWORK_FEES"; + /// The bank's own capital: what its owners paid in. + public static final String SYS_EQUITY = "SYS_EQUITY"; + /// Profit kept from closed periods; period close moves revenue and + /// expense here. + public static final String SYS_RETAINED = "SYS_RETAINED"; + /// Interest the bank pays on savings. + public static final String SYS_INTEREST = "SYS_INTEREST"; + /// Goodwill credits and corrections an administrator posts. + public static final String SYS_ADJUST = "SYS_ADJUST"; + + private AccountKinds() { + } + + public static boolean isSystem(String kind) { + return kind != null && kind.startsWith("SYS_"); + } + + public static boolean isCustomer(String kind) { + return CHECKING.equals(kind) || SAVINGS.equals(kind) || WALLET.equals(kind) + || HOLD.equals(kind); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/IsolationCheck.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/IsolationCheck.java new file mode 100644 index 00000000000..9593829bd4c --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/IsolationCheck.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ledger; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import java.io.IOException; +import java.util.Map; + +/// Refuses to start on a MySQL server that runs at REPEATABLE READ, its +/// default. +/// +/// The ledger, the audit chain and the order book each serialize their +/// writers on one row -- a balance, `audit_head`, a market -- and then read +/// what the writer before them committed. At REPEATABLE READ, InnoDB answers +/// those reads from the snapshot the transaction took at its first read, +/// which can predate the lock: two appends then both chain onto the same +/// audit row, and a cancel releases funds a concurrent fill already moved. +/// READ COMMITTED reads what was committed when each statement runs, as +/// PostgreSQL does by default; SQLite serializes every writer and needs +/// nothing. +/// +/// The level is the server's (or the account's) setting, not the +/// application's, so it is checked rather than set: +/// `SET GLOBAL transaction_isolation = 'READ-COMMITTED'`, or +/// `transaction-isolation = READ-COMMITTED` in the server's configuration. +@Component +public class IsolationCheck { + private final DataSource db; + + public IsolationCheck(DataSource db) { + this.db = db; + } + + @PostConstruct + public void check() throws IOException { + if (!"mysql".equals(db.dialect().getName())) { + return; + } + Object level = level(sql -> { + Map row = db.queryOne(sql, null); + return row == null ? null : row.get("level"); + }); + if (!"READ-COMMITTED".equals(String.valueOf(level))) { + throw new IllegalStateException("Bastion needs MySQL at READ COMMITTED, and this " + + "server runs at " + level + ": SET GLOBAL transaction_isolation = " + + "'READ-COMMITTED' (see IsolationCheck)"); + } + } + + /// One query against the server. + public interface Query { + Object level(String sql) throws IOException; + } + + /// The server's isolation level. `@@transaction_isolation` is MySQL's + /// (since 5.7.20) and MariaDB's only from 11.1; older MariaDB -- 10.11 + /// LTS among them -- knows it only as `@@tx_isolation`, and refuses the + /// first with "Unknown system variable". So the second is asked when the + /// first is refused. + public static Object level(Query q) throws IOException { + try { + return q.level("SELECT @@transaction_isolation AS level"); + } catch (IOException | RuntimeException unknown) { + return q.level("SELECT @@tx_isolation AS level"); + } + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/Ledger.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/Ledger.java new file mode 100644 index 00000000000..75bfcab89b0 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/Ledger.java @@ -0,0 +1,442 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ledger; + +import java.time.Instant; +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.domain.Asset; +import com.codenameone.examples.bastion.domain.JournalEntry; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.domain.Posting; +import com.codenameone.examples.bastion.repository.AssetRepository; +import com.codenameone.examples.bastion.repository.JournalEntryRepository; +import com.codenameone.examples.bastion.repository.LedgerAccountRepository; +import com.codenameone.examples.bastion.repository.PostingRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The double-entry ledger every balance in this bank lives in, fiat and +/// crypto alike. +/// +/// Three rules, each enforced here and nowhere else: +/// +/// 1. **Every entry sums to zero in every asset.** Money is never created or +/// destroyed, only moved; a deposit moves it out of the bank's `SYS_CASH` +/// account, which is why that one may go negative. +/// 2. **A customer's balance never goes below zero**, and a frozen account +/// neither pays nor receives. Each balance change is one conditional +/// `UPDATE ... WHERE balance + delta >= 0`, so the database decides +/// atomically and two concurrent transfers cannot both spend the same +/// dollar. A refusal rolls the whole entry back. +/// 3. **The same idempotency key moves money once.** A retried request gets +/// the entry its first attempt made; a different request with the same +/// key is refused, never answered with somebody else's result. +/// +/// Balances are a cache of the postings, kept in the same transaction, and +/// [#verify] recomputes them. +@Component +public class Ledger { + private final AssetRepository assets; + private final LedgerAccountRepository accounts; + private final JournalEntryRepository entries; + private final PostingRepository postings; + private final DataSource db; + + public Ledger(AssetRepository assets, LedgerAccountRepository accounts, + JournalEntryRepository entries, PostingRepository postings, DataSource db) { + this.db = db; + this.assets = assets; + this.accounts = accounts; + this.entries = entries; + this.postings = postings; + } + + /// The asset `code` names, or 400 when there is none or it is disabled. + @Transactional(readOnly = true) + public Asset asset(String code) throws IOException { + Asset asset = code == null ? null : assets.findById(code); + if (asset == null || !asset.isEnabled()) { + throw Refused.with(400, "no-such-asset", "No such asset"); + } + return asset; + } + + @Transactional(readOnly = true) + public List assets() throws IOException { + return assets.findByEnabledTrueOrderByCodeAsc(); + } + + /// Opens an account for `owner`. A customer may hold several of a kind. + @Transactional(rollbackFor = Exception.class) + public LedgerAccount open(String owner, String assetCode, String kind, String name) + throws IOException { + Asset asset = asset(assetCode); + if (!AccountKinds.isCustomer(kind) && !AccountKinds.isSystem(kind)) { + throw Refused.with(400, "no-such-kind-account", "No such kind of account"); + } + if (AccountKinds.isSystem(kind) != AccountKinds.BANK.equals(owner)) { + throw new IllegalArgumentException("Only the bank holds SYS_ accounts"); + } + // A hold is either: a buy order holds fiat, a sell order holds coins. + boolean either = AccountKinds.isSystem(kind) || AccountKinds.HOLD.equals(kind); + if (!either && "CRYPTO".equals(asset.getKind()) != AccountKinds.WALLET.equals(kind)) { + throw Refused.with(400, "crypto-held-wallet-fiat-account", "Crypto is held in a wallet, fiat in an account"); + } + LedgerAccount account = new LedgerAccount(); + account.setId(Ids.next()); + account.setOwner(owner); + account.setAsset(asset.getCode()); + account.setKind(kind); + account.setName(name == null ? "" : name); + account.setCreatedAt(Instant.now()); + accounts.insert(account); + return account; + } + + /// The bank's account of `kind` in `asset`, opened the first time it is + /// asked for. + @Transactional(rollbackFor = Exception.class) + public LedgerAccount system(String assetCode, String kind) throws IOException { + LedgerAccount found = accounts.findFirstByOwnerAndAssetAndKind(AccountKinds.BANK, + assetCode, kind); + return found != null ? found : open(AccountKinds.BANK, assetCode, kind, kind); + } + + @Transactional(readOnly = true) + public List accountsOf(String owner) throws IOException { + return accounts.findByOwnerOrderByCreatedAtAsc(owner); + } + + /// `owner`'s account `id`, or 404 -- the same answer whether the account + /// does not exist or belongs to someone else, so an id cannot be probed. + @Transactional(readOnly = true) + public LedgerAccount owned(String owner, String id) throws IOException { + LedgerAccount account = id == null ? null : accounts.findById(id); + if (account == null || !account.getOwner().equals(owner)) { + throw Refused.with(404, "no-such-account", "No such account"); + } + return account; + } + + /// `owner`'s account `id` for a customer to move money into or out of: + /// [#owned], but never an order HOLD. A hold belongs to the customer only + /// as the exchange's escrow of an open order; moving it out directly + /// would drain the order's backing while the order still rests on the + /// book. Same 404 as a missing account. + @Transactional(readOnly = true) + public LedgerAccount movable(String owner, String id) throws IOException { + LedgerAccount account = owned(owner, id); + if (AccountKinds.HOLD.equals(account.getKind())) { + throw Refused.with(404, "no-such-account", "No such account"); + } + return account; + } + + @Transactional(readOnly = true) + public List history(String accountId) throws IOException { + return postings.findTop100ByAccountIdOrderByCreatedAtDescIdDesc(accountId); + } + + /// The entry `id`, or null when there is none. + @Transactional(readOnly = true) + public JournalEntry entry(String id) throws IOException { + return id == null ? null : entries.findById(id); + } + + @Transactional(rollbackFor = Exception.class) + public void freeze(String accountId, boolean frozen) throws IOException { + if (accounts.setFrozen(accountId, frozen) != 1) { + throw Refused.with(404, "no-such-account", "No such account"); + } + } + + /// The journal key for a client's `Idempotency-Key`. Every journal key + /// shares one unique index, and the exchange posts its own entries under + /// system keys (`hold:`, `release:`, `trade:` and the seeding keys). A + /// client key stored as sent could claim one of those first -- or another + /// customer's key -- and turn the system's posting into a "retry" of the + /// client's. So a client key is scoped to its owner and hashed under a + /// `c:` prefix no system key starts with, and a `:` in it is refused so a + /// key can never be read as a system one. 66 characters, within the column. + public static String clientKey(String owner, String key) throws IOException { + // The client makes base64url keys, which never hold ':'; a key that + // does is answered like any other malformed one. + if (key == null || key.length() < 16 || key.length() > 80 || key.indexOf(':') >= 0) { + throw Refused.with(400, "send-idempotency-key-16-80", "Send an Idempotency-Key of 16 to 80 characters"); + } + return "c:" + Ids.sha256(owner + "\n" + key); + } + + /// Moves value. See the class comment for the rules; a refusal is a 409 + /// and moves nothing. + @Transactional(rollbackFor = Exception.class) + public JournalEntry post(String idempotencyKey, String kind, String initiator, String memo, + List legs) throws IOException { + return post(idempotencyKey, kind, initiator, memo, legs, System.currentTimeMillis(), + false); + } + + /// [#post] dated `at` instead of now: for the demo's history and for + /// accruals. A date at or before the end of the last closed period is + /// refused -- a closed period is final, and a correction to it is posted + /// in the open period, as a reversal or an adjustment. + @Transactional(rollbackFor = Exception.class) + public JournalEntry postAt(String idempotencyKey, String kind, String initiator, String memo, + List legs, long at) throws IOException { + return post(idempotencyKey, kind, initiator, memo, legs, at, false); + } + + /// The end of the newest closed period, or 0 when none is closed. + @Transactional(readOnly = true) + public long closedThrough() throws IOException { + List rows = db.query("SELECT MAX(closed_through) AS t FROM period_close", new Object[0]); + if (rows == null || rows.isEmpty() || !(rows.get(0) instanceof Map)) { + return 0L; + } + return number(((Map) rows.get(0)).get("t")); + } + + /// Reverses entry `entryId`: posts its mirror image now, in the open + /// period, and records the pair. An entry is reversed at most once (the + /// `reversal` table's key decides, so two reversals racing cannot both + /// commit), and neither a reversal nor a closing entry is reversed -- + /// the first would be undone by reversing it again in the other + /// direction, the second belongs to a closed period. A reversal that would + /// overdraw a customer is refused like any other movement. + @Transactional(rollbackFor = Exception.class) + public JournalEntry reverse(String idempotencyKey, String entryId, String initiator, + String memo) throws IOException { + JournalEntry original = entryId == null ? null : entries.findById(entryId); + if (original == null) { + throw Refused.with(404, "no-such-entry", "No such journal entry"); + } + if (REVERSAL.equals(original.getKind()) || CLOSING.equals(original.getKind())) { + throw Refused.with(409, "entry-cannot-reversed", "That entry cannot be reversed"); + } + List legs = new ArrayList(); + for (Posting p : postings.findByEntryId(entryId)) { + legs.add(new Leg(p.getAccountId(), -p.getAmount())); + } + String earlier = reversedBy(entryId); + if (earlier != null) { + // The same request retried is answered with its reversal; any + // other is a second reversal of one entry. + JournalEntry retried = entries.findFirstByIdempotencyKey(idempotencyKey); + if (retried != null && retried.getId().equals(earlier)) { + return retried; + } + throw Refused.with(409, "entry-already-reversed", "That entry was already reversed"); + } + JournalEntry entry = post(idempotencyKey, REVERSAL, initiator, + "Reversal of " + entryId + (memo == null || memo.length() == 0 ? "" : ": " + memo), + legs, System.currentTimeMillis(), false); + db.execute("INSERT INTO reversal (entry_id, reversed_by, at) VALUES (?, ?, ?)", + new Object[] {entryId, entry.getId(), Long.valueOf(entry.getCreatedAt().toEpochMilli())}); + return entry; + } + + /// The entry that reversed `entryId`, or null. + @Transactional(readOnly = true) + public String reversedBy(String entryId) throws IOException { + List rows = db.query("SELECT reversed_by AS r FROM reversal WHERE entry_id = ?", + new Object[] {entryId}); + if (rows == null || rows.isEmpty() || !(rows.get(0) instanceof Map)) { + return null; + } + Object r = ((Map) rows.get(0)).get("r"); + return r == null ? null : String.valueOf(r); + } + + /// Journal kinds the books treat specially. + public static final String REVERSAL = "REVERSAL"; + public static final String CLOSING = "CLOSING"; + + /// Posts a closing entry dated at the end of the period being closed, + /// which the closed-period rule would otherwise refuse. Only period close + /// calls this, in the transaction that records the close. + @Transactional(rollbackFor = Exception.class) + public JournalEntry postClosing(String idempotencyKey, String initiator, String memo, + List legs, long at) throws IOException { + return post(idempotencyKey, CLOSING, initiator, memo, legs, at, true); + } + + private JournalEntry post(String idempotencyKey, String kind, String initiator, String memo, + List legs, long at, boolean closing) throws IOException { + if (idempotencyKey == null || idempotencyKey.length() < 16 + || idempotencyKey.length() > 80) { + // 16 characters at least: a key a client can collide with by + // accident is a key that drops a real payment as a "retry". + throw Refused.with(400, "send-idempotency-key-16-80", "Send an Idempotency-Key of 16 to 80 characters"); + } + if (legs == null || legs.size() < 2) { + throw Refused.with(400, "entry-needs-two-sides", "An entry needs two sides"); + } + String hash = requestHash(kind, memo, legs); + JournalEntry earlier = entries.findFirstByIdempotencyKey(idempotencyKey); + if (earlier != null) { + if (!earlier.getRequestHash().equals(hash)) { + throw Refused.with(422, "idempotency-key-reused", "That Idempotency-Key was already used for a different request"); + } + return earlier; + } + + if (!closing && at <= closedThrough()) { + throw Refused.with(409, "period-closed", "That date is in a closed period"); + } + + Map perAsset = new HashMap(); + Map touched = new HashMap(); + for (Leg leg : legs) { + if (leg.amount() == 0) { + throw Refused.with(400, "amount-cannot-zero", "An amount cannot be zero"); + } + LedgerAccount account = touched.get(leg.accountId()); + if (account == null) { + account = leg.accountId() == null ? null : accounts.findById(leg.accountId()); + if (account == null) { + throw Refused.with(404, "no-such-account", "No such account"); + } + touched.put(account.getId(), account); + } + Long sum = perAsset.get(account.getAsset()); + long next = (sum == null ? 0L : sum.longValue()) + leg.amount(); + // Overflow is the one way a sum of longs can lie about being zero. + if (sum != null && ((sum.longValue() ^ next) & (leg.amount() ^ next)) < 0) { + throw Refused.with(400, "amount-too-large", "That amount is too large"); + } + perAsset.put(account.getAsset(), Long.valueOf(next)); + } + for (Map.Entry sum : perAsset.entrySet()) { + if (sum.getValue().longValue() != 0L) { + throw new IllegalStateException("Unbalanced entry in " + sum.getKey()); + } + } + + long now = at; + JournalEntry entry = new JournalEntry(); + entry.setId(Ids.next()); + entry.setIdempotencyKey(idempotencyKey); + entry.setRequestHash(hash); + entry.setKind(kind); + entry.setInitiator(initiator == null ? "" : initiator); + entry.setMemo(memo == null ? "" : (memo.length() > 140 ? memo.substring(0, 140) : memo)); + entry.setCreatedAt(Instant.ofEpochMilli(now)); + // Written first: a concurrent request with the same key fails on the + // unique index here, before either has touched a balance. + entries.insert(entry); + + // Balances in a fixed order -- by account id -- so two entries over + // the same accounts take their row locks in the same order and never + // deadlock each other. + List ordered = new ArrayList(legs); + Collections.sort(ordered, new Comparator() { + @Override + public int compare(Leg a, Leg b) { + return a.accountId().compareTo(b.accountId()); + } + }); + for (Leg leg : ordered) { + LedgerAccount account = touched.get(leg.accountId()); + int applied = AccountKinds.isSystem(account.getKind()) + ? accounts.applyUnguarded(account.getId(), leg.amount()) + : accounts.applyGuarded(account.getId(), leg.amount()); + if (applied != 1) { + // Why it was refused, asked of the table: the copy in hand + // was read before this update and may predate a freeze + // committed since, which would be answered as no money. + throw accounts.frozenNow(account.getId()) > 0 + ? Refused.with(409, "account-frozen", "That account is frozen") + : Refused.with(409, "insufficient-funds", "Insufficient funds"); + } + Posting posting = new Posting(); + posting.setId(Ids.next()); + posting.setEntryId(entry.getId()); + posting.setAccountId(account.getId()); + posting.setAsset(account.getAsset()); + posting.setAmount(leg.amount()); + posting.setCreatedAt(Instant.ofEpochMilli(now)); + postings.insert(posting); + } + return entry; + } + + @Transactional(readOnly = true) + public List postingsOf(String entryId) throws IOException { + return postings.findByEntryId(entryId); + } + + /// Recomputes every balance from its postings and checks every asset sums + /// to zero. Empty means the books balance. + @Transactional(readOnly = true) + public List verify(List owners) throws IOException { + List problems = new ArrayList(); + for (Asset asset : assets.findAll()) { + Long total = postings.assetTotal(asset.getCode()); + if (total != null && total.longValue() != 0L) { + problems.add(asset.getCode() + " sums to " + total + ", not zero"); + } + } + // Balance and postings in ONE statement, so both come from one + // snapshot. Read separately at READ COMMITTED, a transfer committing + // between the two reads showed a balance that its postings did not + // yet (or no longer) explain -- a false alarm on a sound ledger. + for (String owner : owners) { + List rows = db.query("SELECT a.id AS id, a.balance AS balance, " + + "COALESCE(SUM(p.amount), 0) AS total FROM ledger_account a " + + "LEFT JOIN posting p ON p.account_id = a.id WHERE a.owner = ? " + + "GROUP BY a.id, a.balance", new Object[] {owner}); + for (Object o : rows) { + Map row = (Map) o; + long balance = number(row.get("balance")); + long expected = number(row.get("total")); + if (expected != balance) { + problems.add("account " + row.get("id") + " holds " + balance + + " but its postings say " + expected); + } + } + } + return problems; + } + + static long number(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + return value == null ? 0L : Long.parseLong(String.valueOf(value).trim()); + } + + static String requestHash(String kind, String memo, List legs) throws IOException { + StringBuilder b = new StringBuilder(); + b.append(kind).append('|').append(memo == null ? "" : memo).append('|'); + for (Leg leg : legs) { + b.append(leg.accountId()).append('=').append(leg.amount()).append(';'); + } + return Ids.sha256(b.toString()); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/Leg.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/Leg.java new file mode 100644 index 00000000000..f973f15c4db --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/Leg.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ledger; + +/// One side of a movement: `amount` ledger units into (positive) or out of +/// (negative) an account. +public final class Leg { + private final String accountId; + private final long amount; + + public Leg(String accountId, long amount) { + this.accountId = accountId; + this.amount = amount; + } + + public String accountId() { + return accountId; + } + + public long amount() { + return amount; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/AppAttestVerifier.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/AppAttestVerifier.java new file mode 100644 index 00000000000..2eff49c9184 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/AppAttestVerifier.java @@ -0,0 +1,387 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +import com.codename1.backend.Base64Url; +import com.codename1.backend.Crypto; +import com.codename1.backend.security.pki.Certificate; +import com.codename1.backend.security.pki.CertificateValidator; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Verifies an Apple App Attest *attestation* server side, following Apple's +/// "Validating apps that connect to your server" steps. +/// +/// The token is what the iOS port sends for the first request of a key's +/// life: `cn1aa1:attest::`, where the +/// attestation object is the CBOR map `{fmt: "apple-appattest", attStmt: +/// {x5c, receipt}, authData}` and the client data hash is SHA-256 of the +/// server nonce -- here the device public key the registration carries. +/// +/// Checked: the x5c chain validates to the trust anchors (Apple's App +/// Attestation Root CA in production); SHA-256(authData || clientDataHash) +/// equals the nonce in the credential certificate's extension +/// `1.2.840.113635.100.8.2`; the key identifier is SHA-256 of the credential +/// key's uncompressed point; the rpIdHash is SHA-256 of `.`; +/// the counter is 0; the AAGUID is `appattest` or `appattestdevelop`; the +/// credential id equals the key identifier. The receipt is not sent to Apple's +/// fraud-metric service. Assertion tokens (`cn1aa1:assert:...`) and every +/// failure answer "not attested"; other platforms go to the delegate. +public final class AppAttestVerifier implements IntegrityVerifier { + /// Apple's App Attestation Root CA, as published at + /// https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem + public static final String APPLE_APP_ATTESTATION_ROOT_CA = + "-----BEGIN CERTIFICATE-----\n" + + "MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw\n" + + "JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK\n" + + "QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa\n" + + "Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv\n" + + "biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y\n" + + "bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh\n" + + "NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au\n" + + "Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/\n" + + "MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw\n" + + "CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn\n" + + "53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV\n" + + "oyFraWVIyd/dganmrduC1bmTBGwD\n" + + "-----END CERTIFICATE-----\n"; + + private static final String PREFIX = "cn1aa1:attest:"; + /// DER of the OID 1.2.840.113635.100.8.2, tag and length included. + private static final byte[] NONCE_OID = {0x06, 0x09, 0x2a, (byte) 0x86, 0x48, (byte) 0x86, + (byte) 0xf7, 0x63, 0x64, 0x08, 0x02}; + + private final List anchors; + private final byte[] rpIdHash; + private final byte[] aaguid; + private final IntegrityVerifier others; + + /// @param anchors the roots the x5c chain must reach; Apple's root in production + /// @param appId `.` + /// @param production true for the `appattest` environment, false for `appattestdevelop` + /// @param others verifies every platform but `ios` + public AppAttestVerifier(List anchors, String appId, boolean production, + IntegrityVerifier others) { + this.anchors = new ArrayList(anchors); + this.rpIdHash = Crypto.sha256(utf8(appId)); + this.aaguid = Arrays.copyOf(utf8(production ? "appattest" : "appattestdevelop"), 16); + this.others = others; + } + + /// A verifier anchored at Apple's App Attestation Root CA. + public static AppAttestVerifier apple(String appId, boolean production, + IntegrityVerifier others) throws IOException { + return new AppAttestVerifier( + Collections.singletonList(Certificate.fromPem(APPLE_APP_ATTESTATION_ROOT_CA)), + appId, production, others); + } + + @Override + public Verdict verify(String platform, String token, boolean reportedRooted, + boolean reportedAttested) throws IOException { + return verify(platform, token, null, reportedRooted, reportedAttested); + } + + @Override + public Verdict verify(String platform, String token, String nonce, boolean reportedRooted, + boolean reportedAttested) throws IOException { + if (!"ios".equals(platform)) { + return others.verify(platform, token, nonce, reportedRooted, reportedAttested); + } + return new Verdict(nonce != null && token != null && attests(token, nonce), reportedRooted); + } + + private boolean attests(String token, String nonce) { + try { + return check(token, nonce); + } catch (IOException | RuntimeException e) { + return false; // malformed input of any kind is simply not an attestation + } + } + + @SuppressWarnings("unchecked") + private boolean check(String token, String nonce) throws IOException { + if (!token.startsWith(PREFIX)) { + return false; + } + // indexOf, not split: the compiled backend's String has no split. + String rest = token.substring(PREFIX.length()); + int colon = rest.indexOf(':'); + if (colon < 0 || rest.indexOf(':', colon + 1) >= 0) { + return false; + } + byte[] keyId = base64(rest.substring(0, colon)); + byte[] object = base64(rest.substring(colon + 1)); + if (keyId == null || object == null) { + return false; + } + Object decoded = new MiniCbor(object).read(); + if (!(decoded instanceof Map)) { + return false; + } + Map map = (Map) decoded; + Object stmt = map.get("attStmt"); + Object authDataValue = map.get("authData"); + if (!"apple-appattest".equals(map.get("fmt")) || !(stmt instanceof Map) + || !(authDataValue instanceof byte[])) { + return false; + } + Object x5c = ((Map) stmt).get("x5c"); + if (!(x5c instanceof List) || ((List) x5c).isEmpty()) { + return false; + } + List chain = new ArrayList(); + for (Object der : (List) x5c) { + if (!(der instanceof byte[])) { + return false; + } + chain.add(Certificate.decode((byte[]) der)); + } + // Step 1: the chain reaches a trust anchor. + new CertificateValidator(anchors).validate(chain); + Certificate credCert = chain.get(0); + byte[] authData = (byte[]) authDataValue; + // Steps 2-4: nonce = SHA-256(authData || SHA-256(nonce)) is in the cred cert. + byte[] clientDataHash = Crypto.sha256(utf8(nonce)); + byte[] composite = new byte[authData.length + clientDataHash.length]; + System.arraycopy(authData, 0, composite, 0, authData.length); + System.arraycopy(clientDataHash, 0, composite, authData.length, clientDataHash.length); + if (!Arrays.equals(Crypto.sha256(composite), nonceExtension(credCert.getEncoded()))) { + return false; + } + // Step 5: the key identifier is SHA-256 of the uncompressed EC point. + byte[] spki = credCert.getPublicKey(); + if (spki.length < 65 || spki[spki.length - 65] != 0x04) { + return false; + } + byte[] point = Arrays.copyOfRange(spki, spki.length - 65, spki.length); + if (!Arrays.equals(Crypto.sha256(point), keyId)) { + return false; + } + // Steps 6-9: rpIdHash, counter 0, AAGUID, credential id. + if (authData.length < 55 + || !Arrays.equals(Arrays.copyOfRange(authData, 0, 32), rpIdHash)) { + return false; + } + for (int i = 33; i < 37; i++) { + if (authData[i] != 0) { + return false; + } + } + if (!Arrays.equals(Arrays.copyOfRange(authData, 37, 53), aaguid)) { + return false; + } + int idLength = ((authData[53] & 0xff) << 8) | (authData[54] & 0xff); + return authData.length >= 55 + idLength + && Arrays.equals(Arrays.copyOfRange(authData, 55, 55 + idLength), keyId); + } + + /// The nonce inside extension 1.2.840.113635.100.8.2: + /// `SEQUENCE { [1] EXPLICIT OCTET STRING }`, or null. + private static byte[] nonceExtension(byte[] der) { + Der cert = new Der(der, 0); + Der tbs = new Der(der, cert.contentStart); + int at = tbs.contentStart; + while (at < tbs.end) { + Der field = new Der(der, at); + if ((der[at] & 0xff) == 0xa3) { + Der list = new Der(der, field.contentStart); + int e = list.contentStart; + while (e < list.end) { + Der extension = new Der(der, e); + int oid = extension.contentStart; + if (regionEquals(der, oid, NONCE_OID)) { + Der value = new Der(der, oid + NONCE_OID.length); + if ((der[value.start] & 0xff) == 0x01) { // critical flag + value = new Der(der, value.end); + } + Der seq = new Der(der, value.contentStart); + Der tagged = new Der(der, seq.contentStart); + Der octets = new Der(der, tagged.contentStart); + if ((der[tagged.start] & 0xff) != 0xa1 || (der[octets.start] & 0xff) != 0x04) { + return null; + } + return Arrays.copyOfRange(der, octets.contentStart, octets.end); + } + e = extension.end; + } + return null; + } + at = field.end; + } + return null; + } + + private static boolean regionEquals(byte[] data, int offset, byte[] expected) { + if (offset + expected.length > data.length) { + return false; + } + for (int i = 0; i < expected.length; i++) { + if (data[offset + i] != expected[i]) { + return false; + } + } + return true; + } + + private static byte[] base64(String standard) { + StringBuilder url = new StringBuilder(standard.length()); + for (int i = 0; i < standard.length(); i++) { + char c = standard.charAt(i); + if (c == '+') { + url.append('-'); + } else if (c == '/') { + url.append('_'); + } else if (c != '=') { + url.append(c); + } + } + return Base64Url.decode(url.toString()); + } + + private static byte[] utf8(String s) { + try { + return s.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + + /// One DER TLV header: where it starts, where its content starts and ends. + private static final class Der { + final int start; + final int contentStart; + final int end; + + Der(byte[] data, int at) { + start = at; + int p = at + 1; + int length = data[p++] & 0xff; + if (length > 0x80) { + int count = length & 0x7f; + if (count > 3) { + throw new IllegalArgumentException("DER length"); + } + length = 0; + for (int i = 0; i < count; i++) { + length = (length << 8) | (data[p++] & 0xff); + } + } else if (length == 0x80) { + throw new IllegalArgumentException("Indefinite DER length"); + } + contentStart = p; + end = p + length; + if (end > data.length) { + throw new IllegalArgumentException("Truncated DER"); + } + } + } + + /// The CBOR subset an attestation object uses: unsigned and negative + /// integers, byte and text strings, arrays and maps, definite lengths only. + private static final class MiniCbor { + private final byte[] data; + private int at; + private int depth; + + MiniCbor(byte[] data) { + this.data = data; + } + + Object read() throws IOException { + if (++depth > 8) { + throw new IOException("CBOR nested too deeply"); + } + int initial = data[at++] & 0xff; + int major = initial >> 5; + long value = argument(initial & 0x1f); + Object out; + switch (major) { + case 0: + out = Long.valueOf(value); + break; + case 1: + out = Long.valueOf(-1 - value); + break; + case 2: + out = bytes(value); + break; + case 3: + out = new String(bytes(value), "UTF-8"); + break; + case 4: { + List list = new ArrayList(); + for (long i = 0; i < value; i++) { + list.add(read()); + } + out = list; + break; + } + case 5: { + Map map = new HashMap(); + for (long i = 0; i < value; i++) { + Object key = read(); + map.put(key, read()); + } + out = map; + break; + } + default: + throw new IOException("Unsupported CBOR major type " + major); + } + depth--; + return out; + } + + private byte[] bytes(long length) throws IOException { + if (length > data.length - at) { + throw new IOException("Truncated CBOR"); + } + byte[] out = Arrays.copyOfRange(data, at, at + (int) length); + at += (int) length; + return out; + } + + private long argument(int info) throws IOException { + if (info < 24) { + return info; + } + int size; + if (info == 24) { + size = 1; + } else if (info == 25) { + size = 2; + } else if (info == 26) { + size = 4; + } else { + throw new IOException("Unsupported CBOR length"); + } + long value = 0; + for (int i = 0; i < size; i++) { + value = (value << 8) | (data[at++] & 0xff); + } + return value; + } + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/BankingEndpoint.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/BankingEndpoint.java new file mode 100644 index 00000000000..94c601e9f23 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/BankingEndpoint.java @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +import com.codenameone.examples.bastion.repository.ProfileRepository; +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.bastion.api.AccountDto; +import com.codenameone.examples.bastion.api.AssetDto; +import com.codenameone.examples.bastion.api.BankingApiServer; +import com.codenameone.examples.bastion.api.CardDto; +import com.codenameone.examples.bastion.api.CardLimitDto; +import com.codenameone.examples.bastion.api.CardSpendDto; +import com.codenameone.examples.bastion.api.CashDto; +import com.codenameone.examples.bastion.api.DeviceDto; +import com.codenameone.examples.bastion.api.DeviceRegistrationDto; +import com.codenameone.examples.bastion.api.DeviceRevokeDto; +import com.codenameone.examples.bastion.api.NewPayeeDto; +import com.codenameone.examples.bastion.api.OpenAccountDto; +import com.codenameone.examples.bastion.api.PayeeDto; +import com.codenameone.examples.bastion.api.PostingDto; +import com.codenameone.examples.bastion.api.ReceiptDto; +import com.codenameone.examples.bastion.api.TransferDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.Asset; +import com.codenameone.examples.bastion.domain.Card; +import com.codenameone.examples.bastion.domain.Device; +import com.codenameone.examples.bastion.domain.JournalEntry; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.domain.Payee; +import com.codenameone.examples.bastion.domain.Posting; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Ledger; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// [com.codenameone.examples.bastion.api.BankingApi], served. +/// +/// Every call acts on the caller's own money, named by their token: an +/// account, payee or card id in a request is looked up *through* the caller, +/// and anyone else's answers 404 exactly as one that does not exist does. +/// The signature is checked here, before any transaction opens, so that its +/// nonce is spent and its refusal audited whatever the movement then does. +@Component +public class BankingEndpoint implements BankingApiServer { + /// More accounts than a person keeps; a ceiling on rows a token can make. + private static final int MAX_ACCOUNTS = 20; + + private final Ledger ledger; + private final Money money; + private final Payees payees; + private final Cards cards; + private final Devices devices; + private final RequestSigning signing; + private final Audit audit; + private final Config config; + private final ProfileRepository profiles; + + public BankingEndpoint(Ledger ledger, Money money, Payees payees, Cards cards, + Devices devices, RequestSigning signing, Audit audit, Config config, + ProfileRepository profiles) { + this.profiles = profiles; + this.ledger = ledger; + this.money = money; + this.payees = payees; + this.cards = cards; + this.devices = devices; + this.signing = signing; + this.audit = audit; + this.config = config; + } + + @Override + public List assets() throws Exception { + List out = new ArrayList(); + for (Asset a : ledger.assets()) { + out.add(new AssetDto(a.getCode(), a.getName(), a.getKind(), a.getDecimals())); + } + return out; + } + + @Override + public List accounts() throws Exception { + String owner = Devices.customer(); + List out = new ArrayList(); + for (LedgerAccount a : ledger.accountsOf(owner)) { + if (!AccountKinds.HOLD.equals(a.getKind())) { + out.add(dto(a)); + } + } + return out; + } + + @Override + public AccountDto open(OpenAccountDto request) throws Exception { + String owner = Devices.customer(); + if (request == null) { + throw Refused.with(400, "say-which-account-open", "Say which account to open"); + } + String kind = request.kind(); + if (!AccountKinds.CHECKING.equals(kind) && !AccountKinds.SAVINGS.equals(kind) + && !AccountKinds.WALLET.equals(kind)) { + throw Refused.with(400, "open-checking-savings-wallet-account", "Open a CHECKING, SAVINGS or WALLET account"); + } + return dto(openAudited(owner, request.asset(), kind, request.name())); + } + + @Transactional(rollbackFor = Exception.class) + private LedgerAccount openAudited(String owner, String asset, String kind, String name) + throws IOException { + // Behind the customer's profile row: two of these at once would each + // count the rows before the other inserted and together pass the cap. + profiles.hold(owner); + if (ledger.accountsOf(owner).size() >= MAX_ACCOUNTS) { + throw Refused.with(409, "close-account-before-opening-another", "Close an account before opening another"); + } + String clipped = name == null ? kind : (name.length() > 80 ? name.substring(0, 80) : name); + LedgerAccount account = ledger.open(owner, asset, kind, clipped); + audit.append(owner, "ACCOUNT_OPENED", account.getId(), kind + " " + asset); + return account; + } + + @Override + public List history(String id) throws Exception { + String owner = Devices.customer(); + LedgerAccount account = ledger.owned(owner, id); + List out = new ArrayList(); + // At most a hundred postings, and most share no entry; each entry is + // read once for its kind and description. + java.util.Map entries = new java.util.HashMap(); + for (Posting p : ledger.history(account.getId())) { + JournalEntry entry = entries.get(p.getEntryId()); + if (entry == null && !entries.containsKey(p.getEntryId())) { + entry = ledger.entry(p.getEntryId()); + entries.put(p.getEntryId(), entry); + } + out.add(new PostingDto(p.getId(), p.getEntryId(), p.getAsset(), p.getAmount(), + p.getCreatedAt().toEpochMilli(), entry == null ? "" : entry.getKind(), + entry == null ? "" : entry.getMemo())); + } + return out; + } + + @Override + public ReceiptDto transfer(String idempotencyKey, String signature, TransferDto transfer) + throws Exception { + String owner = Devices.customer(); + if (transfer == null || transfer.fromAccountId() == null + || (transfer.toAccountId() == null) == (transfer.payeeId() == null)) { + throw Refused.with(400, "name-account-pay-either-account", "Name the account to pay from, and either an account or a payee to pay"); + } + LedgerAccount from = ledger.movable(owner, transfer.fromAccountId()); + boolean leaves = transfer.payeeId() != null; + Device device = null; + // A signature that is sent is always checked, even where none is + // needed: one that is wrong is never ignored. + if (money.needsSignature(from, transfer.amount(), leaves) || RequestSigning.present()) { + device = signing.require(owner); + } + if (!leaves) { + LedgerAccount to = ledger.movable(owner, transfer.toAccountId()); + return money.internal(owner, idempotencyKey, from, to, transfer.amount(), + transfer.memo()); + } + money.requireTrusted(device); + Payee payee = payees.owned(owner, transfer.payeeId()); + List theirs = ledger.accountsOf(payee.getUsername()); + LedgerAccount to = null; + for (LedgerAccount a : theirs) { + // A frozen account is passed over, so paying a frozen customer + // gets the same "cannot receive" as one with no such account: + // the payer is not told that someone else's account is frozen. + if (to == null && AccountKinds.CHECKING.equals(a.getKind()) + && a.getAsset().equals(from.getAsset()) && !a.isFrozen()) { + to = a; + } + } + if (to == null) { + throw Refused.with(409, "payee-cannot-receive", "That payee cannot receive {0}", from.getAsset()); + } + return money.pay(owner, idempotencyKey, from, payee, to, transfer.amount(), + transfer.memo()); + } + + @Override + public ReceiptDto deposit(String id, String idempotencyKey, CashDto cash) throws Exception { + String owner = Devices.customer(); + if (!config.isDevelopmentProfile()) { + // Money appears from nowhere: a simulation for the demo, which no + // production server has. + throw Refused.with(404, "not-found", "Not found"); + } + LedgerAccount to = ledger.movable(owner, id); + return money.deposit(owner, idempotencyKey, to, cash == null ? 0 : cash.amount(), + cash == null ? null : cash.memo()); + } + + @Override + public ReceiptDto withdraw(String id, String idempotencyKey, String signature, CashDto cash) + throws Exception { + String owner = Devices.customer(); + LedgerAccount from = ledger.movable(owner, id); + // Always signed, whatever the policy: money leaving the bank does not + // come back. + Device device = signing.require(owner); + money.requireTrusted(device); + return money.withdraw(owner, idempotencyKey, from, cash == null ? 0 : cash.amount(), + cash == null ? null : cash.memo()); + } + + @Override + public List payees() throws Exception { + return payees.list(Devices.customer()); + } + + @Override + public PayeeDto addPayee(String signature, NewPayeeDto payee) throws Exception { + String owner = Devices.customer(); + Device device = signing.require(owner); + money.requireTrusted(device); + return payees.add(owner, payee); + } + + @Override + public PayeeDto renamePayee(String id, String signature, NewPayeeDto payee) + throws Exception { + // Signed: the name is what the customer picks a payee by, so a token + // alone could otherwise relabel an attacker's payee as the landlord. + String owner = Devices.customer(); + signing.require(owner); + return payees.rename(owner, id, payee); + } + + @Override + public PayeeDto removePayee(String id, String signature) throws Exception { + String owner = Devices.customer(); + signing.require(owner); + return payees.remove(owner, id); + } + + @Override + public List cards() throws Exception { + return cards.list(Devices.customer()); + } + + @Override + public CardDto issueCard(String accountId, String signature) throws Exception { + // Signed by a trusted device: a card is one more way for money to + // leave the account, so a token alone may not mint one. + String owner = Devices.customer(); + Device device = signing.require(owner); + money.requireTrusted(device); + return cards.issue(owner, ledger.movable(owner, accountId)); + } + + @Override + public CardDto freezeCard(String id) throws Exception { + return cards.freeze(Devices.customer(), id, true); + } + + @Override + public CardDto unfreezeCard(String id, String signature) throws Exception { + // Freezing stays a token-only call -- anyone holding the session may + // stop a card, and should be able to the moment it is lost -- but + // unfreezing puts spending back, so it is held to what moving money is. + String owner = Devices.customer(); + Device device = signing.require(owner); + money.requireTrusted(device); + return cards.freeze(owner, id, false); + } + + @Override + public CardDto cardLimit(String id, String signature, CardLimitDto limit) throws Exception { + if (limit == null) { + throw Refused.with(400, "send-limit", "Send the limit"); + } + String owner = Devices.customer(); + // Lowering a limit is as harmless as freezing; raising one is more + // spending, so it is signed by a trusted device. Whether this is a + // raise is decided by Cards.limit behind the card's row: compared + // here, a concurrent lower committed in between would let an unsigned + // request "lower" the limit back up. A signature, when sent, is + // checked first (it can be used only once). + boolean signed = false; + if (RequestSigning.present()) { + Device device = signing.require(owner); + money.requireTrusted(device); + signed = true; + } + return cards.limit(owner, id, limit.dailyLimit(), signed); + } + + @Override + public ReceiptDto spend(String id, String idempotencyKey, CardSpendDto spend) + throws Exception { + String owner = Devices.customer(); + if (!config.isDevelopmentProfile()) { + // A real authorization arrives from the card network over its own + // authenticated channel, never from the cardholder's token. + throw Refused.with(404, "not-found", "Not found"); + } + Card card = cards.owned(owner, id); + if (card.isFrozen()) { + audit.append(owner, "CARD_DECLINED", id, "frozen"); + throw Refused.with(409, "card-frozen", "That card is frozen"); + } + LedgerAccount from = ledger.movable(owner, card.getAccountId()); + return money.spend(owner, idempotencyKey, card, from, spend == null ? 0 : spend.amount(), + spend == null ? null : spend.merchant()); + } + + @Override + public List devices() throws Exception { + return devices.list(Devices.customer()); + } + + @Override + public DeviceDto registerDevice(DeviceRegistrationDto device) throws Exception { + return devices.register(Devices.customer(), device); + } + + @Override + public DeviceDto revokeDevice(String id, String signature, DeviceRevokeDto request) + throws Exception { + String owner = Devices.customer(); + // A stolen token must not revoke the owner's phone, which would leave + // the thief's session as the only one that can approve anything. One + // of the account's devices signs it (another, or the one going), or, + // with none at hand, the password is entered again. + if (RequestSigning.present()) { + signing.require(owner); + } else { + devices.recheck(owner, request == null ? null : request.password()); + } + return devices.revoke(owner, id); + } + + private static AccountDto dto(LedgerAccount a) { + return new AccountDto(a.getId(), a.getAsset(), a.getKind(), a.getName(), a.getBalance(), + a.isFrozen(), a.getCreatedAt().toEpochMilli()); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Cards.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Cards.java new file mode 100644 index 00000000000..24223a5f80a --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Cards.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +import java.time.Instant; +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.Crypto; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.api.CardDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.Card; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.repository.CardRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Virtual cards: one per checking account, frozen and unfrozen by the +/// customer, each with a daily spending limit of its own under the +/// customer's overall one. +/// +/// The card number is simulated. A real issuer's processor holds the number +/// in its PCI-scoped vault and the bank keeps a token and the last four +/// digits, which is all this table has: there is no full number here to +/// steal. +@Component +public class Cards { + /// 500.00 a day until the customer says otherwise. + static final long DEFAULT_LIMIT = 50000L; + static final long MAX_LIMIT = 500000L; + + private final CardRepository cards; + private final Audit audit; + + public Cards(CardRepository cards, Audit audit) { + this.cards = cards; + this.audit = audit; + } + + @Transactional(rollbackFor = Exception.class) + public CardDto issue(String owner, LedgerAccount account) throws IOException { + if (!AccountKinds.CHECKING.equals(account.getKind())) { + throw Refused.with(400, "cards-issued-checking-accounts", "Cards are issued on checking accounts"); + } + if (cards.findFirstByAccountId(account.getId()) != null) { + throw Refused.with(409, "account-already-has-card", "That account already has a card"); + } + byte[] random = Crypto.randomBytes(2); + int digits = (((random[0] & 0xff) << 8) | (random[1] & 0xff)) % 10000; + String last4 = Integer.toString(10000 + digits).substring(1); + Card card = new Card(); + card.setId(Ids.next()); + card.setOwner(owner); + card.setAccountId(account.getId()); + card.setLast4(last4); + card.setDailyLimit(DEFAULT_LIMIT); + card.setCreatedAt(Instant.now()); + cards.insert(card); + audit.append(owner, "CARD_ISSUED", card.getId(), "account " + account.getId() + + " ending " + last4); + return dto(card); + } + + @Transactional(readOnly = true) + public List list(String owner) throws IOException { + List out = new ArrayList(); + for (Card c : cards.findByOwnerOrderByCreatedAtAsc(owner)) { + out.add(dto(c)); + } + return out; + } + + /// `owner`'s card `id`, or 404 whether it is missing or someone else's. + @Transactional(readOnly = true) + public Card owned(String owner, String id) throws IOException { + Card card = id == null ? null : cards.findById(id); + if (card == null || !card.getOwner().equals(owner)) { + throw Refused.with(404, "no-such-card", "No such card"); + } + return card; + } + + /// Freezing needs no signature: it only ever stops money, and a customer + /// who has lost a phone must be able to do it from anywhere. + @Transactional(rollbackFor = Exception.class) + public CardDto freeze(String owner, String id, boolean frozen) throws IOException { + Card card = owned(owner, id); + cards.setFrozen(id, owner, frozen); + card.setFrozen(frozen); + audit.append(owner, frozen ? "CARD_FROZEN" : "CARD_UNFROZEN", id, ""); + return dto(card); + } + + /// Sets the card's daily limit; a raise only when `mayRaise` (the request + /// was signed by a trusted device). Compared behind the card's row, so the + /// limit compared against is the one being replaced. + /// + /// The limit is in the card account's own currency, as a card network + /// counts it (a euro card's limit is euros), and so is [#MAX_LIMIT]. It is + /// not the only bound: card spending is also an outgoing movement, held + /// to the USD-valued per-payment and daily limits every movement is. + @Transactional(rollbackFor = Exception.class) + public CardDto limit(String owner, String id, long limit, boolean mayRaise) + throws IOException { + if (limit < 0 || limit > MAX_LIMIT) { + throw Refused.with(400, "card-limit-range", "A card's limit is 0 to {0}", MAX_LIMIT); + } + // The hold first, the read after it: a row read earlier in this + // transaction would be answered from the session, as it was before + // the lock (a concurrent change committed in between unseen). + if (id != null) { + cards.hold(id); + } + Card card = owned(owner, id); + if (limit > card.getDailyLimit() && !mayRaise) { + throw Refused.with(403, "needs-device-signature", + "This needs a signature from your device: {0}", "missing"); + } + cards.setLimit(id, owner, limit); + audit.append(owner, "CARD_LIMIT", id, card.getDailyLimit() + " -> " + limit); + card.setDailyLimit(limit); + return dto(card); + } + + static CardDto dto(Card c) { + return new CardDto(c.getId(), c.getAccountId(), c.getLast4(), c.isFrozen(), + c.getDailyLimit(), c.getCreatedAt().toEpochMilli()); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Devices.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Devices.java new file mode 100644 index 00000000000..715d69578c9 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Devices.java @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +import java.time.Instant; +import com.codenameone.examples.bastion.repository.ProfileRepository; +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.Base64Url; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.crypto.Der; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.mfa.TotpService; +import com.codenameone.examples.bastion.account.SignInGuard; +import com.codenameone.examples.bastion.Caller; +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.api.DeviceDto; +import com.codenameone.examples.bastion.api.DeviceRegistrationDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.Device; +import com.codenameone.examples.bastion.repository.DeviceRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The devices that may sign requests for a customer, and what each one is +/// trusted with. +/// +/// A device is **trusted** when the platform attested it and it is not +/// rooted. With `bastion.device.policy.enforce` on (the default), only a +/// trusted device can send money to someone else, add a payee or withdraw: +/// an untrusted one is held to moving money between the customer's own +/// accounts, which is the most malware on a rooted phone could then do. +/// +/// **Registering a key needs more than a token.** A device key is what +/// signs money out of the bank, so a stolen access token -- or a refresh +/// token kept alive for hours -- must not be enough to add one. Two checks: +/// +/// 1. **A fresh sign-in.** The token's `auth_time` claim ([Caller#authTime]) +/// must be under `bastion.device.fresh-minutes` (10) old. A refresh keeps +/// the original sign-in time, so only someone who signed in just now +/// passes. +/// 2. **A second factor, used in that sign-in.** A token whose sign-in was +/// made with the password alone (`amr` holding none of `otp`, `mfa` or +/// `hwk`) is refused whatever it carries, even the password: a password is +/// the one thing a phished or stolen session is most likely to come with, +/// and the key registered here is what signs payments. The customer is +/// asked to set up an authenticator app or a passkey and sign in with it +/// -- even on an account that has an app now, since the app may have been +/// enrolled with the very token being used. A passkey sign-in the +/// authenticator did not verify the user for (`hwk` alone) must carry the +/// password as well; a wrong one counts toward the sign-in lockout. The token says how +/// its sign-in was made ([Caller#authMethods], the `amr` claim): a sign-in +/// with a user-verified passkey (`hwk` together with `user`) already proved +/// possession of a key bound to this site and the person holding it, so it +/// is not asked for a password it may not even have. `hwk` alone does not +/// count: a key that was merely present proves nothing about who used it. +/// +/// Every registration and every refusal is audited, and the customer's +/// device list shows every key. +@Component +public class Devices { + /// A customer with more keys than this is a customer whose token is being + /// used to register them. + static final int MAX_DEVICES = 10; + + private final DeviceRepository devices; + private final ProfileRepository profiles; + private final IntegrityVerifier integrity; + private final Audit audit; + private final TotpService totp; + private final JdbcUserDetailsManager users; + private final PasswordEncoder encoder; + private final SignInGuard guard; + private final long freshSeconds; + + public Devices(DeviceRepository devices, IntegrityVerifier integrity, Audit audit, + TotpService totp, JdbcUserDetailsManager users, PasswordEncoder encoder, + SignInGuard guard, ProfileRepository profiles, + @Value("${bastion.device.fresh-minutes:10}") String freshMinutes) { + this.devices = devices; + this.profiles = profiles; + this.integrity = integrity; + this.audit = audit; + this.totp = totp; + this.users = users; + this.encoder = encoder; + this.guard = guard; + this.freshSeconds = Long.parseLong(freshMinutes.trim()) * 60L; + } + + /// Registers a key once the caller has shown a fresh sign-in, and the + /// second factor used in that sign-in; see the class comment. The + /// refusals are audited here, outside the registration's transaction. + public DeviceDto register(String owner, DeviceRegistrationDto request) throws IOException { + if (request == null || request.publicKey() == null) { + throw Refused.with(400, "send-device-public-key", "Send the device's public key"); + } + long age = System.currentTimeMillis() / 1000L - Caller.authTime(); + if (Caller.authTime() < 0 || age > freshSeconds || age < -60L) { + audit.append(owner, "DEVICE_REFUSED", owner, "sign-in not fresh"); + throw Refused.with(403, "sign-again-add-device-sign", "Sign in again to add a device: the sign-in must be a few minutes old"); + } + java.util.List how = Caller.authMethods(); + // The token's own record of how its sign-in was made decides, never + // whether the account has an authenticator app *now*: a thief with a + // fresh password-only token could enrol an app of their own and then + // pass as "second factor". Only an `otp`/`mfa` sign-in, or a passkey + // one the authenticator verified the user for (`hwk` with `user`), is + // spared the password: `hwk` alone is a key that was merely present, + // which proves possession of the device and nothing about who holds it. + boolean second = how.contains("mfa") || how.contains("otp") || how.contains("hwk"); + if (!second) { + audit.append(owner, "DEVICE_REFUSED", owner, "no second factor, amr " + how); + throw Refused.with(403, "second-factor-add-device", + "Set up an authenticator app or a passkey, then sign in with it to add a device"); + } + boolean strong = how.contains("mfa") || how.contains("otp") + || (how.contains("hwk") && how.contains("user")); + if (!strong) { + guard.recheck(owner, request.password(), encoder, "DEVICE_REFUSED", + "enter-password-add-device-turn", + "Enter your password to add a device, or turn on an authenticator app"); + } + return registerOnce(owner, request, how); + } + + @Transactional(rollbackFor = Exception.class) + private DeviceDto registerOnce(String owner, DeviceRegistrationDto request, + java.util.List how) throws IOException { + byte[] key = decode(request.publicKey()); + // A P-256 key and nothing else: that is the curve ES256 is defined + // over, and the one every phone's secure hardware can hold. + try { + if (key == null || !Der.EC.equals(Der.publicKeyType(key)) + || !Der.P256.equals(Der.ecCurve(key))) { + throw Refused.with(400, "key-ec-p-256-public", "The key must be an EC P-256 public key"); + } + } catch (IOException unreadable) { + throw Refused.with(400, "key-ec-p-256-public", "The key must be an EC P-256 public key"); + } + // Behind the customer's profile row: two of these at once would each + // count the rows before the other inserted and together pass the cap. + profiles.hold(owner); + List held = devices.findByOwnerOrderByCreatedAtAsc(owner); + int live = 0; + for (Device d : held) { + live += d.isRevoked() ? 0 : 1; + } + if (live >= MAX_DEVICES) { + throw Refused.with(409, "remove-device-before-adding-another", "Remove a device before adding another"); + } + String platform = clip(request.platform(), 20); + IntegrityVerifier.Verdict verdict = integrity.verify(platform, request.integrityToken(), + request.publicKey(), request.rooted(), request.attested()); + Device device = new Device(); + device.setId(Ids.next()); + device.setOwner(owner); + device.setPublicKey(request.publicKey()); + device.setPlatform(platform); + device.setRooted(verdict.rooted()); + device.setAttested(verdict.attested()); + device.setCreatedAt(Instant.now()); + devices.insert(device); + audit.append(owner, "DEVICE_REGISTERED", device.getId(), platform + + (trusted(device) ? " trusted" : " untrusted") + + (device.isRooted() ? " rooted" : "") + (device.isAttested() ? " attested" : "") + // How the sign-in that added it was made, for whoever reads + // the trail after a takeover. + + " amr " + how); + return dto(device); + } + + @Transactional(readOnly = true) + public List list(String owner) throws IOException { + List out = new ArrayList(); + for (Device d : devices.findByOwnerOrderByCreatedAtAsc(owner)) { + if (!d.isRevoked()) { + out.add(dto(d)); + } + } + return out; + } + + /// The password again, for revoking a device that no device signs for. + public void recheck(String owner, String password) throws IOException { + guard.recheck(owner, password, encoder, "DEVICE_REVOKE_REFUSED", + "sign-or-enter-password-revoke", + "Approve this on another device, or enter your password"); + } + + @Transactional(rollbackFor = Exception.class) + public DeviceDto revoke(String owner, String id) throws IOException { + Device device = id == null ? null : devices.findById(id); + if (device == null || !device.getOwner().equals(owner) + || devices.revoke(id, owner) != 1) { + throw Refused.with(404, "no-such-device", "No such device"); + } + device.setRevoked(true); + audit.append(owner, "DEVICE_REVOKED", id, device.getPlatform()); + return dto(device); + } + + /// `owner`'s live device `id`, or null. + @Transactional(readOnly = true) + public Device live(String owner, String id) throws IOException { + Device device = id == null ? null : devices.findById(id); + if (device == null || device.isRevoked() || !device.getOwner().equals(owner)) { + return null; + } + return device; + } + + public static boolean trusted(Device device) { + return device != null && device.isAttested() && !device.isRooted() + && !device.isRevoked(); + } + + static byte[] decode(String base64url) { + try { + return Base64Url.decode(base64url); + } catch (RuntimeException malformed) { + return null; + } + } + + private static DeviceDto dto(Device d) { + return new DeviceDto(d.getId(), d.getPlatform(), d.isRooted(), d.isAttested(), trusted(d), + d.getCreatedAt().toEpochMilli()); + } + + private static String clip(String value, int max) { + String v = value == null ? "" : value; + return v.length() <= max ? v : v.substring(0, max); + } + + /// The caller, who must be a customer: staff do not bank on their admin + /// account, and an admin token is not a way into anyone's money. + public static String customer() { + String name = Caller.name(); + if (!Caller.hasRole(com.codenameone.examples.bastion.Roles.CUSTOMER)) { + throw Refused.with(403, "customers-hold-accounts", "Only customers hold accounts"); + } + return name; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/InsightsEndpoint.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/InsightsEndpoint.java new file mode 100644 index 00000000000..8d225ac8946 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/InsightsEndpoint.java @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.bastion.Caller; +import com.codenameone.examples.bastion.api.AllocationDto; +import com.codenameone.examples.bastion.api.BalancePointDto; +import com.codenameone.examples.bastion.api.CandleDto; +import com.codenameone.examples.bastion.api.CashflowDayDto; +import com.codenameone.examples.bastion.api.InsightsApiServer; +import com.codenameone.examples.bastion.books.Books; +import com.codenameone.examples.bastion.books.BooksEndpoint; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.exchange.Markets; +import com.codenameone.examples.bastion.ledger.Ledger; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/// [com.codenameone.examples.bastion.api.InsightsApi], served: chart data +/// over the caller's own accounts, derived from the postings, plus market +/// candles from the trade tape. +/// +/// Days are UTC and bucketed here, not in SQL, because SQLite, PostgreSQL +/// and MySQL each spell date arithmetic differently (MySQL's `/` is not even +/// integer division). +@Component +public class InsightsEndpoint implements InsightsApiServer { + static final long MINUTE = 60000L; + + private final Ledger ledger; + private final Books books; + private final Markets markets; + + public InsightsEndpoint(Ledger ledger, Books books, Markets markets) { + this.ledger = ledger; + this.books = books; + this.markets = markets; + } + + @Override + public List balanceHistory(String id, String days) throws Exception { + String owner = Devices.customer(); + LedgerAccount account = ledger.owned(owner, id); + int n = BooksEndpoint.clamp(days, 30, 366); + long today = Books.day(System.currentTimeMillis()); + long start = today - (n - 1) * Books.DAY; + long balance = Books.num(books.rows("SELECT COALESCE(SUM(amount), 0) AS t FROM posting " + + "WHERE account_id = ? AND created_at < ?", + new Object[] {account.getId(), Long.valueOf(start)}).get(0), "t"); + List rows = books.rows("SELECT amount, created_at FROM posting WHERE account_id = ? " + + "AND created_at >= ? ORDER BY created_at ASC, id ASC", + new Object[] {account.getId(), Long.valueOf(start)}); + List out = new ArrayList(); + int at = 0; + for (long d = start; d <= today; d += Books.DAY) { + while (at < rows.size() && Books.num(rows.get(at), "created_at") < d + Books.DAY) { + balance += Books.num(rows.get(at), "amount"); + at++; + } + out.add(new BalancePointDto(d, balance)); + } + return out; + } + + @Override + public List cashflow(String asset, String days) throws Exception { + String owner = Devices.customer(); + String code = asset == null || asset.length() == 0 ? "USD" : asset; + ledger.asset(code); + int n = BooksEndpoint.clamp(days, 30, 366); + long today = Books.day(System.currentTimeMillis()); + long start = today - (n - 1) * Books.DAY; + Map perEntry = new LinkedHashMap(); + for (Map r : books.rows("SELECT p.entry_id AS entry_id, p.amount AS amount, " + + "p.created_at AS created_at FROM posting p JOIN ledger_account a " + + "ON a.id = p.account_id WHERE a.owner = ? AND a.asset = ? AND p.created_at >= ? " + + "ORDER BY p.created_at ASC", new Object[] {owner, code, Long.valueOf(start)})) { + String entry = Books.str(r, "entry_id"); + long[] e = perEntry.get(entry); + if (e == null) { + e = new long[] {Books.num(r, "created_at"), 0L}; + perEntry.put(entry, e); + } + e[1] += Books.num(r, "amount"); + } + // Netted per entry over all of the caller's accounts, so a move from + // checking to savings -- or into an order's hold -- is neither in nor out. + TreeMap perDay = new TreeMap(); + for (long d = start; d <= today; d += Books.DAY) { + perDay.put(Long.valueOf(d), new long[2]); + } + for (long[] e : perEntry.values()) { + long[] day = perDay.get(Long.valueOf(Books.day(e[0]))); + if (day == null || e[1] == 0) { + continue; + } + day[e[1] > 0 ? 0 : 1] += e[1] > 0 ? e[1] : -e[1]; + } + List out = new ArrayList(); + for (Map.Entry d : perDay.entrySet()) { + long[] v = d.getValue(); + out.add(new CashflowDayDto(d.getKey().longValue(), v[0], v[1], v[0] - v[1])); + } + return out; + } + + @Override + public List allocation() throws Exception { + String owner = Devices.customer(); + TreeMap held = new TreeMap(); + for (LedgerAccount a : ledger.accountsOf(owner)) { + Long sum = held.get(a.getAsset()); + held.put(a.getAsset(), Long.valueOf((sum == null ? 0L : sum.longValue()) + + a.getBalance())); + } + long total = 0; + Map usd = new LinkedHashMap(); + for (Map.Entry h : held.entrySet()) { + long v = books.usd(h.getKey(), h.getValue().longValue()); + usd.put(h.getKey(), Long.valueOf(v)); + total += v; + } + List out = new ArrayList(); + for (Map.Entry h : held.entrySet()) { + long v = usd.get(h.getKey()).longValue(); + int bps = total <= 0 ? 0 : (int) (v * 10000L / total); + out.add(new AllocationDto(h.getKey(), h.getValue().longValue(), v, bps)); + } + return out; + } + + @Override + public List candles(String id, String interval, String limit) throws Exception { + Caller.name(); + markets.get(id); + long step = BooksEndpoint.number(interval, 0L); + step = step <= 0 ? 60 * MINUTE : step; + if (step < MINUTE || step > Books.DAY) { + step = step < MINUTE ? MINUTE : Books.DAY; + } + int n = BooksEndpoint.clamp(limit, 168, 500); + long now = System.currentTimeMillis(); + long last = now - now % step; + long start = last - (n - 1) * step; + List before = books.rows("SELECT price FROM trade WHERE market = ? AND created_at < ? " + + "ORDER BY created_at DESC LIMIT 1", new Object[] {id, Long.valueOf(start)}); + long close = before.isEmpty() ? -1L : Books.num(before.get(0), "price"); + List trades = books.rows("SELECT price, quantity, buyer_paid, created_at FROM trade " + + "WHERE market = ? AND created_at >= ? ORDER BY created_at ASC, id ASC", + new Object[] {id, Long.valueOf(start)}); + List out = new ArrayList(); + int at = 0; + for (long s = start; s <= last; s += step) { + long open = -1; + long high = 0; + long low = 0; + long volume = 0; + long quote = 0; + int count = 0; + while (at < trades.size() && Books.num(trades.get(at), "created_at") < s + step) { + Map t = trades.get(at++); + long price = Books.num(t, "price"); + if (open < 0) { + open = price; + high = price; + low = price; + } + high = Math.max(high, price); + low = Math.min(low, price); + close = price; + volume += Books.num(t, "quantity"); + quote += Books.num(t, "buyer_paid"); + count++; + } + if (count == 0) { + if (close < 0) { + // Nothing has traded yet: no candle rather than a made-up one. + continue; + } + out.add(new CandleDto(s, close, close, close, close, 0L, 0L, 0)); + } else { + out.add(new CandleDto(s, open, high, low, close, volume, quote, count)); + } + } + return out; + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/IntegrityVerifier.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/IntegrityVerifier.java new file mode 100644 index 00000000000..ef7e2899cd2 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/IntegrityVerifier.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +/// Checks the integrity token a device sends when it registers, and answers +/// what the platform vouches for. +/// +/// The real check is the platform's: Google Play Integrity on Android (the +/// server sends the token to Google's `decodeIntegrityToken` with the +/// project's service account and reads `deviceIntegrity` and +/// `appIntegrity` out of the verdict), App Attest on iOS. Both need an app +/// built by the Codename One build daemon, whose signing identity is what +/// the verdict names, so this sample ships [SimulatedIntegrityVerifier] and a +/// deployment replaces the bean in [MoneyConfig]. +public interface IntegrityVerifier { + /// What the platform says about the device that sent `token`. + Verdict verify(String platform, String token, boolean reportedRooted, + boolean reportedAttested) throws java.io.IOException; + + /// The same check with the nonce the token was requested over -- the + /// device public key the registration carries -- for verifiers that bind + /// the token to it, as App Attest does. The default ignores the nonce. + default Verdict verify(String platform, String token, String nonce, boolean reportedRooted, + boolean reportedAttested) throws java.io.IOException { + return verify(platform, token, reportedRooted, reportedAttested); + } + + /// A verdict: whether the platform vouched for the app and the device, + /// and whether the device is rooted or jailbroken. + final class Verdict { + private final boolean attested; + private final boolean rooted; + + public Verdict(boolean attested, boolean rooted) { + this.attested = attested; + this.rooted = rooted; + } + + public boolean attested() { + return attested; + } + + public boolean rooted() { + return rooted; + } + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Money.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Money.java new file mode 100644 index 00000000000..0834de5f918 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Money.java @@ -0,0 +1,568 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +import java.time.Instant; +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.bastion.api.ReceiptDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.chain.Chain; +import com.codenameone.examples.bastion.domain.Asset; +import com.codenameone.examples.bastion.domain.Card; +import com.codenameone.examples.bastion.domain.CryptoAddress; +import com.codenameone.examples.bastion.domain.Device; +import com.codenameone.examples.bastion.domain.JournalEntry; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.domain.Market; +import com.codenameone.examples.bastion.domain.Payee; +import com.codenameone.examples.bastion.domain.Trade; +import com.codenameone.examples.bastion.domain.Transfer; +import com.codenameone.examples.bastion.exchange.Markets; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codenameone.examples.bastion.ledger.Leg; +import com.codenameone.examples.bastion.repository.CardRepository; +import com.codenameone.examples.bastion.repository.JournalEntryRepository; +import com.codenameone.examples.bastion.repository.ProfileRepository; +import com.codenameone.examples.bastion.repository.TradeRepository; +import com.codenameone.examples.bastion.repository.TransferRepository; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +/// Every movement of a customer's money: between their own accounts, to a +/// payee, out of the bank, onto a card, and (on a development server) in. +/// +/// Each movement is one transaction holding the ledger entry, the [Transfer] +/// row the limits are counted from, and the audit event: all three or none. +/// What is checked before it, in order: +/// +/// 1. **Ownership.** The source account is the caller's, or 404. +/// 2. **The signature** ([RequestSigning]) when the movement needs one: above +/// `bastion.stepup.threshold`, and always for money leaving the caller -- +/// to a payee or out of the bank -- while `bastion.device.policy.enforce` +/// is on, since that is how the server knows which device asked. +/// 3. **The device** is trusted ([Devices]) for money leaving the caller. +/// 4. **The limits** ([#checkLimits]): per movement, per 24 hours, and per +/// new payee during its cooling-off period -- fiat valued in USD at +/// `bastion.limit.fx` and summed across currencies (the card limit in +/// the card's own units), crypto +/// valued in USD at the index price. +/// +/// A retry of a movement already made skips 2 to 4's counting -- the money +/// moved, and counting it again would refuse a retry of a payment that was +/// within its limits -- and answers the first receipt. +@Component +public class Money { + static final long DAY = 24L * 3600L * 1000L; + + private final Ledger ledger; + private final Audit audit; + private final TransferRepository transfers; + private final JournalEntryRepository entries; + private final ProfileRepository profiles; + private final CardRepository cards; + private final long perTransfer; + private final long daily; + private final long stepUp; + private final long coolOffMillis; + private final long coolOffLimit; + private final boolean enforceDevices; + private final Markets markets; + private final long cryptoPerMove; + private final long cryptoDaily; + private final Map fxBps; + private final TradeRepository trades; + private final Chain chain; + + public Money(Ledger ledger, Audit audit, TransferRepository transfers, + JournalEntryRepository entries, ProfileRepository profiles, Markets markets, + CardRepository cards, TradeRepository trades, Chain chain, + @Value("${bastion.crypto.limit.per-withdrawal:1000000}") String cryptoPerMove, + @Value("${bastion.crypto.limit.daily:2000000}") String cryptoDaily, + @Value("${bastion.limit.per-transfer:500000}") String perTransfer, + @Value("${bastion.limit.daily:1000000}") String daily, + @Value("${bastion.stepup.threshold:100000}") String stepUp, + @Value("${bastion.payee.cooloff-hours:24}") String coolOffHours, + @Value("${bastion.payee.cooloff-limit:10000}") String coolOffLimit, + @Value("${bastion.device.policy.enforce:true}") String enforceDevices, + @Value("${bastion.limit.fx:EUR=11000}") String fx) { + this.ledger = ledger; + this.trades = trades; + this.chain = chain; + this.audit = audit; + this.transfers = transfers; + this.entries = entries; + this.profiles = profiles; + this.cards = cards; + // In USD cents, every fiat currency valued in USD: 5,000.00 a movement, 10,000.00 + // a day, a signature above 1,000.00, 100.00 to a payee for its first + // day. + this.perTransfer = Long.parseLong(perTransfer.trim()); + this.daily = Long.parseLong(daily.trim()); + this.stepUp = Long.parseLong(stepUp.trim()); + this.coolOffMillis = Long.parseLong(coolOffHours.trim()) * 3600L * 1000L; + this.coolOffLimit = Long.parseLong(coolOffLimit.trim()); + this.enforceDevices = "true".equalsIgnoreCase(enforceDevices.trim()); + this.markets = markets; + // In USD cents at the index price, across every coin together: + // 10,000.00 a movement, 20,000.00 a day. + this.cryptoPerMove = Long.parseLong(cryptoPerMove.trim()); + this.cryptoDaily = Long.parseLong(cryptoDaily.trim()); + this.fxBps = parseFx(fx); + } + + /// `bastion.limit.fx`: `CODE=bps,...`, what one unit of a fiat currency + /// is worth in USD, in basis points (`EUR=11000` is 1.1000 USD). The fiat + /// limits are in USD cents, so each currency is valued at this rate first; + /// a fiat asset not listed is valued at its index market, if it has one, + /// and refused if not. + static Map parseFx(String fx) { + Map out = new HashMap(); + String text = fx == null ? "" : fx.trim(); + int start = 0; + while (start < text.length()) { + int comma = text.indexOf(',', start); + String pair = (comma < 0 ? text.substring(start) : text.substring(start, comma)).trim(); + start = comma < 0 ? text.length() : comma + 1; + int colon = pair.indexOf('='); + if (colon <= 0) { + if (pair.length() > 0) { + throw new IllegalArgumentException("bastion.limit.fx: " + pair); + } + continue; + } + long bps = Long.parseLong(pair.substring(colon + 1).trim()); + if (bps <= 0) { + throw new IllegalArgumentException("bastion.limit.fx: " + pair); + } + out.put(pair.substring(0, colon).trim(), Long.valueOf(bps)); + } + return out; + } + + /// `amount` ledger units of fiat `code` in USD cents, rounded up. Every + /// fiat asset here has two decimals, as USD does. + long fiatValue(String code, long amount) throws IOException { + if ("USD".equals(code) || amount == 0) { + return amount; + } + Long bps = fxBps.get(code); + if (bps == null) { + return markets.value(code, amount, "USD"); + } + long b = bps.longValue(); + if (amount > Long.MAX_VALUE / b) { + return Long.MAX_VALUE; + } + long scaled = amount * b; + return scaled / 10000L + (scaled % 10000L > 0 ? 1L : 0L); + } + + /// Whether [#usdValue] can value `asset`, answered without refusing. + @Transactional(readOnly = true) + public boolean hasUsdPrice(String asset) throws IOException { + return "USD".equals(asset) || fxBps.containsKey(asset) || markets.hasPrice(asset, "USD"); + } + + /// `amount` ledger units of any asset in USD cents, rounded up: a fiat + /// currency at its configured rate, a coin at its index price. + public long usdValue(String asset, long amount) throws IOException { + Asset a = ledger.asset(asset); + return "FIAT".equals(a.getKind()) ? fiatValue(asset, Math.max(amount, 0L)) + : markets.value(asset, Math.max(amount, 0L), "USD"); + } + + /// What `owner` has traded since `since`, both sides, in USD cents: the + /// quote each fill paid or received, valued like any movement. + /// Counting the buy side too, and filled orders' holds with the open + /// ones, is deliberately conservative: it can refuse a customer early, + /// never let one past the daily limit. Telling "value leaving" apart + /// per side would need a valuation of every coin, and an error there + /// would fail open. + private long tradedSince(String owner, long since) throws IOException { + long sum = 0; + for (Trade t : trades.since(owner, Instant.ofEpochMilli(since))) { + Market market = markets.find(t.getMarket()); + if (market == null) { + continue; + } + long quote = owner.equals(t.getBuyer()) ? t.getBuyerPaid() : t.getSellerGot(); + sum = saturatedAdd(sum, usdValue(market.getQuote(), quote)); + } + return sum; + } + + /// Takes the customer's row for an order's limit check, as a payment + /// does: two orders at once then cannot each pass alone. Before the + /// open orders are read for [#checkTrade]. + public void holdCustomer(String owner) throws IOException { + profiles.hold(owner); + } + + /// Holds a customer's order, worth at most `value` USD cents, to the + /// limits a payment is held to: no more than one payment may move, and + /// with `open` (what the customer's other open orders could still carry + /// away, in USD) and the day's payments and trades together no more than + /// the daily limit. Open orders count because a resting order is checked + /// once, when placed, and may fill later in one go: many small ones + /// would each pass alone. A trade is value leaving the customer as surely as a payment + /// is -- a thief selling the account's coins into their own resting + /// order, or buying from it -- and the price collar bounds only how bad + /// each fill can be, not how many there are. Behind the customer's row + /// ([#holdCustomer]), taken by the caller. + /// + /// Called inside the order's transaction, after the market lock: the + /// order is then market, customer, balances, audit, and no path takes + /// a customer's row before a market's. + public void checkTrade(String owner, long value, long open) throws IOException { + if (value > perTransfer) { + throw Refused.with(409, "more-than-payment-may-move", "That is more than one payment may move"); + } + long now = System.currentTimeMillis(); + long sent = saturatedAdd(saturatedAdd(value, open), tradedSince(owner, now - DAY)); + sent = saturatedAdd(sent, fiatSentSince(owner, now - DAY)); + if (sent > daily) { + throw Refused.with(409, "would-pass-limit-day", "That would pass your limit for the day"); + } + } + + /// What `owner` has sent in every fiat currency since `since`, in USD cents. + private long fiatSentSince(String owner, long since) throws IOException { + long sent = 0; + for (Asset fiat : ledger.assets()) { + if ("FIAT".equals(fiat.getKind())) { + Long moved = transfers.sentSince(owner, fiat.getCode(), Instant.ofEpochMilli(since)); + if (moved != null && moved.longValue() > 0) { + sent = saturatedAdd(sent, fiatValue(fiat.getCode(), moved.longValue())); + } + } + } + return sent; + } + + public long coolOffMillis() { + return coolOffMillis; + } + + public boolean enforcesDevices() { + return enforceDevices; + } + + /// Whether a movement of `amount` out of `from` that does or does not + /// leave the caller must be signed. The threshold is in fiat units; a + /// coin amount is valued in USD at the index price first, or 0.001 BTC + /// and 1,000 BTC would be the same number of satoshi short of it. + public boolean needsSignature(LedgerAccount from, long amount, boolean leaves) + throws IOException { + if (leaves && enforceDevices) { + return true; + } + Asset asset = ledger.asset(from.getAsset()); + long value = "FIAT".equals(asset.getKind()) ? fiatValue(asset.getCode(), Math.max(amount, 0L)) + : markets.value(asset.getCode(), Math.max(amount, 0L), "USD"); + return value > stepUp; + } + + /// Refuses a device that may not send money away, when the policy says so. + public void requireTrusted(Device device) { + if (enforceDevices && !Devices.trusted(device)) { + throw Refused.with(403, "device-not-attested", "This device is not attested, or is rooted: it can move money only between your own accounts"); + } + } + + /// Moves `amount` between two of the caller's accounts. + public ReceiptDto internal(String owner, String key, LedgerAccount from, LedgerAccount to, + long amount, String memo) throws IOException { + if (from.getId().equals(to.getId())) { + throw Refused.with(400, "choose-two-different-accounts", "Choose two different accounts"); + } + // A move is one asset in and the same out; changing one into another + // is a trade, at a price, on the exchange. + if (!from.getAsset().equals(to.getAsset())) { + throw Refused.with(400, "both-accounts-hold-same-currency", "Both accounts must hold the same currency. Use the exchange to convert."); + } + return move(new Move(owner, key, "TRANSFER", from, to, "", amount, false, memo, null)); + } + + /// Pays `payee`'s customer into their checking account in the same asset. + public ReceiptDto pay(String owner, String key, LedgerAccount from, Payee payee, + LedgerAccount to, long amount, String memo) throws IOException { + return move(new Move(owner, key, "PAYMENT", from, to, payee.getId(), amount, true, memo, + payee)); + } + + /// Money out of the bank: into SYS_CASH, standing for the wire or the + /// blockchain transaction that would carry it on. + public ReceiptDto withdraw(String owner, String key, LedgerAccount from, long amount, + String memo) throws IOException { + LedgerAccount cash = ledger.system(from.getAsset(), AccountKinds.SYS_CASH); + return move(new Move(owner, key, "WITHDRAWAL", from, cash, "", amount, true, memo, null)); + } + + /// Coins out of the bank to an allow-listed address. The caller has + /// checked the address is the owner's, for this asset, and past its + /// cooling-off. + /// + /// On a real chain ([Chain#handles]) the coins go to SYS_CHAIN_TRANSIT + /// and the transaction is queued in the same database transaction, so + /// there is never a debit without a withdrawal to settle it nor the + /// reverse; [Chain] signs, broadcasts and settles it. Otherwise they go + /// straight to SYS_CHAIN, standing for the transaction a real exchange + /// would broadcast. + public ReceiptDto withdrawCrypto(String owner, String key, LedgerAccount from, + CryptoAddress address, long amount) throws IOException { + boolean real = chain.handles(from.getAsset()); + LedgerAccount to = ledger.system(from.getAsset(), real ? AccountKinds.SYS_CHAIN_TRANSIT + : AccountKinds.SYS_CHAIN); + Move m = new Move(owner, key, "CRYPTO_OUT", from, to, address.getId(), amount, + true, "to " + address.getAddress(), null); + if (real) { + m.chainTo = address; + } + return move(m); + } + + /// Simulated money in, from SYS_CASH. Not counted against any limit. + public ReceiptDto deposit(String owner, String key, LedgerAccount to, long amount, + String memo) throws IOException { + LedgerAccount cash = ledger.system(to.getAsset(), AccountKinds.SYS_CASH); + return move(new Move(owner, key, "DEPOSIT", cash, to, "", amount, false, memo, null)); + } + + /// A card authorization: refused when the card is frozen or over its own + /// daily limit, then a movement like any other out of the account. + public ReceiptDto spend(String owner, String key, Card card, LedgerAccount from, long amount, + String merchant) throws IOException { + LedgerAccount cash = ledger.system(from.getAsset(), AccountKinds.SYS_CASH); + Move move = new Move(owner, key, "CARD", from, cash, card.getId(), amount, true, + merchant, null); + move.card = card; + return move(move); + } + + /// One movement. See the class comment; this is the half that retries. + private ReceiptDto move(Move m) throws IOException { + if (m.amount <= 0) { + throw Refused.with(400, "amount-more-than-zero", "The amount must be more than zero"); + } + m.key = Ledger.clientKey(m.owner, m.key); + try { + return moveOnce(m); + } catch (ResponseStatusException answered) { + throw answered; + } catch (IOException | RuntimeException failed) { + // Two copies of one request, sent together: both found no entry + // with the key, and the second failed on its unique index when the + // first committed. Its transaction rolled back whole. Asked again, + // it finds the first's entry and answers that receipt. + if (m.key != null && found(m.key)) { + return moveOnce(m); + } + throw failed; + } + } + + @Transactional(readOnly = true) + private boolean found(String key) throws IOException { + return entries.findFirstByIdempotencyKey(key) != null; + } + + @Transactional(rollbackFor = Exception.class) + private ReceiptDto moveOnce(Move m) throws IOException { + java.util.List legs = Arrays.asList(new Leg(m.from.getId(), -m.amount), + new Leg(m.to.getId(), m.amount)); + String memo = m.memo == null ? "" : m.memo; + JournalEntry earlier = m.key == null ? null : entries.findFirstByIdempotencyKey(m.key); + if (earlier != null) { + // The ledger compares the request with the first one and refuses a + // different payment under the same key; then the first receipt. + JournalEntry same = ledger.post(m.key, m.kind, m.owner, memo, legs); + Transfer first = transfers.findById(same.getId()); + if (first == null || !first.getOwner().equals(m.owner)) { + throw Refused.with(422, "idempotency-key-reused", "That Idempotency-Key was already used for a different request"); + } + return receipt(first); + } + if (m.outgoing) { + // Behind the customer's own row: two payments at once would + // otherwise each count the day's total without the other and + // together pass a limit neither may. + profiles.hold(m.owner); + if (m.card != null) { + // The card as it is now, behind its row: the copy the endpoint + // checked was read before this transaction, and a freeze or a + // lower limit committed in between must not be spent past. + cards.hold(m.card.getId()); + Card now = cards.findById(m.card.getId()); + if (now == null || now.isFrozen()) { + throw Refused.with(409, "card-frozen", "That card is frozen"); + } + m.card = now; + } + checkLimits(m); + } + JournalEntry entry = ledger.post(m.key, m.kind, m.owner, memo, legs); + Transfer t = new Transfer(); + t.setId(entry.getId()); + t.setOwner(m.owner); + t.setKind(m.kind); + t.setFromAccount(m.from.getId()); + t.setToAccount(m.to.getId()); + t.setVia(m.via); + t.setAsset(m.from.getAsset()); + t.setAmount(m.amount); + t.setOutgoing(m.outgoing); + t.setMemo(entry.getMemo()); + t.setCreatedAt(entry.getCreatedAt()); + transfers.insert(t); + if (m.chainTo != null) { + chain.queue(entry.getId(), m.owner, m.chainTo, m.amount); + } + audit.append(m.owner, m.kind, entry.getId(), m.amount + " " + t.getAsset() + " " + + t.getFromAccount() + " -> " + t.getToAccount() + + (m.via.length() > 0 ? " via " + m.via : "")); + return receipt(t); + } + + /// The limits. A fiat movement is counted in its own asset's units. A + /// coin is not a cent, so a crypto movement is valued in USD at the index + /// price ([Markets#value], rounded up) and counted against limits shared + /// by every coin: someone draining an account takes the BTC and the ETH + /// both, and one limit per coin would let them take twice. The index + /// price is the bank's own feed, not the last trade, which two colluding + /// customers could print low to slip a withdrawal under the limit. + private void checkLimits(Move m) throws IOException { + Asset asset = ledger.asset(m.from.getAsset()); + if (!"FIAT".equals(asset.getKind())) { + checkCryptoLimits(m, asset); + return; + } + long now = System.currentTimeMillis(); + // In USD cents across every fiat currency together, like the crypto + // limits: one limit per currency would let a customer with a USD and + // a EUR account move the day's limit twice. + long value = fiatValue(asset.getCode(), m.amount); + if (value > perTransfer) { + throw Refused.with(409, "more-than-payment-may-move", "That is more than one payment may move"); + } + // The day's trades count too: one daily limit for everything that + // can carry value away, or a thief would get it once per channel. + long sent = saturatedAdd(value, fiatSentSince(m.owner, now - DAY)); + sent = saturatedAdd(sent, tradedSince(m.owner, now - DAY)); + if (sent > daily) { + throw Refused.with(409, "would-pass-limit-day", "That would pass your limit for the day"); + } + if (m.payee != null && now < m.payee.getCreatedAt().toEpochMilli() + coolOffMillis) { + // In USD cents, each currency valued by itself: a raw sum would + // add euro cents to dollar cents, and the limit would mean a + // different amount in every currency. + long paid = value; + for (Asset fiat : ledger.assets()) { + if ("FIAT".equals(fiat.getKind())) { + Long through = transfers.throughSinceIn(m.payee.getId(), fiat.getCode(), + m.payee.getCreatedAt()); + if (through != null && through.longValue() > 0) { + paid = saturatedAdd(paid, fiatValue(fiat.getCode(), through.longValue())); + } + } + } + if (paid > coolOffLimit) { + throw Refused.with(409, "new-payee-receive-until-their", "A new payee can receive only {0} until their cooling-off period ends", coolOffLimit); + } + } + if (m.card != null) { + Long spent = transfers.throughSince(m.card.getId(), Instant.ofEpochMilli(now - DAY)); + if ((spent == null ? 0L : spent.longValue()) + m.amount > m.card.getDailyLimit()) { + throw Refused.with(409, "over-card-daily-limit", "That is over the card's daily limit"); + } + } + } + + private void checkCryptoLimits(Move m, Asset asset) throws IOException { + long now = System.currentTimeMillis(); + long value = markets.value(asset.getCode(), m.amount, "USD"); + if (value > cryptoPerMove) { + throw Refused.with(409, "more-than-crypto-movement-may", "That is more than one crypto movement may move"); + } + long sent = value; + for (Asset coin : ledger.assets()) { + if (!"FIAT".equals(coin.getKind())) { + Long moved = transfers.sentSince(m.owner, coin.getCode(), Instant.ofEpochMilli(now - DAY)); + if (moved != null && moved.longValue() > 0) { + sent = saturatedAdd(sent, markets.value(coin.getCode(), moved.longValue(), + "USD")); + } + } + } + if (sent > cryptoDaily) { + throw Refused.with(409, "would-pass-crypto-limit-day", "That would pass your crypto limit for the day"); + } + // No payee branch: a payee is paid into a fiat checking account, so + // coins leave a customer only by withdrawal, to an allow-listed address + // whose cooling-off is its own. + } + + private static long saturatedAdd(long a, long b) { + long sum = a + b; + return ((a ^ sum) & (b ^ sum)) < 0 ? Long.MAX_VALUE : sum; + } + + static ReceiptDto receipt(Transfer t) { + return new ReceiptDto(t.getId(), t.getKind(), t.getFromAccount(), t.getToAccount(), + t.getAsset(), t.getAmount(), t.getMemo(), t.getCreatedAt().toEpochMilli()); + } + + /// What one call asked to move. + private static final class Move { + final String owner; + String key; + final String kind; + final LedgerAccount from; + final LedgerAccount to; + final String via; + final long amount; + final boolean outgoing; + final String memo; + final Payee payee; + Card card; + /// Set for a withdrawal [Chain] carries to a real network. + CryptoAddress chainTo; + + Move(String owner, String key, String kind, LedgerAccount from, LedgerAccount to, + String via, long amount, boolean outgoing, String memo, Payee payee) { + this.owner = owner; + this.key = key; + this.kind = kind; + this.from = from; + this.to = to; + this.via = via; + this.amount = amount; + this.outgoing = outgoing; + this.memo = memo; + this.payee = payee; + } + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/MoneyConfig.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/MoneyConfig.java new file mode 100644 index 00000000000..9c710326990 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/MoneyConfig.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.ConditionalOnMissingBean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.Config; +import java.io.IOException; + +/// The pieces of money movement a deployment replaces. +@Configuration +public class MoneyConfig { + /// The device integrity check. Simulated, and only on a development + /// profile (`dev`, `test`, `local`): it believes whatever the device + /// reports, so on any other profile the server refuses to start until + /// the deployment declares its own [IntegrityVerifier] bean -- one that + /// sends the Play Integrity token to Google's `decodeIntegrityToken` and + /// checks an App Attest assertion against Apple's, see [IntegrityVerifier]. + /// Declaring that bean makes this one step aside. + /// + /// Setting `bastion.appattest.app-id` (`.`) verifies + /// iOS attestations for real with [AppAttestVerifier], anchored at Apple's + /// App Attestation Root CA; `bastion.appattest.production=true` expects + /// the production AAGUID instead of the development one. Every other + /// platform stays with the simulated check. + @Bean + @ConditionalOnMissingBean(IntegrityVerifier.class) + public IntegrityVerifier integrityVerifier(Config config) throws IOException { + IntegrityVerifier simulated = SimulatedIntegrityVerifier.forProfile(config.isDevelopmentProfile()); + String appId = config.get("bastion.appattest.app-id", ""); + if (appId.length() == 0) { + return simulated; + } + return AppAttestVerifier.apple(appId, config.getBoolean("bastion.appattest.production", false), + simulated); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Payees.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Payees.java new file mode 100644 index 00000000000..fec1ba455ec --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Payees.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +import java.time.Instant; +import com.codenameone.examples.bastion.repository.ProfileRepository; +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.account.Accounts; +import com.codenameone.examples.bastion.api.NewPayeeDto; +import com.codenameone.examples.bastion.api.PayeeDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.Payee; +import com.codenameone.examples.bastion.repository.PayeeRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The people a customer may pay: other customers of this bank, by e-mail. +/// +/// A payee is where stolen money goes, so adding one takes a signed request +/// from a trusted device, and a new payee is in **cooling-off** for +/// `bastion.payee.cooloff-hours`: in that time it can receive no more than +/// `bastion.payee.cooloff-limit` in all. Whoever takes over a session can add +/// themselves, but cannot empty the account into themselves before the owner +/// sees the notification and the audit trail. +/// +/// A removed payee is flagged, never deleted, and adding the same address +/// again restores that row with its id and creation time: the cooling-off is +/// summed by payee id from that time, so a fresh row would have handed out a +/// fresh allowance to anyone who removes and re-adds. For the same reason +/// new payees are capped per day (`bastion.payee.per-day`), removed ones +/// counted: otherwise a thief adds a payee, pays it, removes it and adds +/// another address. +@Component +public class Payees { + static final int MAX_PAYEES = 200; + static final long DAY = 24L * 3600000L; + + private final PayeeRepository payees; + private final ProfileRepository profiles; + private final Money money; + private final Audit audit; + private final int perDay; + + public Payees(PayeeRepository payees, Money money, Audit audit, + ProfileRepository profiles, + @Value("${bastion.payee.per-day:5}") String perDay) { + this.perDay = Integer.parseInt(perDay.trim()); + this.payees = payees; + this.profiles = profiles; + this.money = money; + this.audit = audit; + } + + @Transactional(rollbackFor = Exception.class) + public PayeeDto add(String owner, NewPayeeDto request) throws IOException { + if (request == null) { + throw Refused.with(400, "name-payee", "Name the payee"); + } + String username; + try { + username = Accounts.email(request.email()); + } catch (ResponseStatusException malformed) { + throw Refused.with(400, "not-e-mail-address", "That is not an e-mail address"); + } + if (username.equals(owner)) { + throw Refused.with(400, "move-money-between-own-accounts", "Move money between your own accounts instead"); + } + // The same answer whether or not anyone banks under that address: + // "no such customer" turned this into a directory of who banks here, + // one signed request per guess. A payee whose address has no account + // is added like any other and simply cannot be paid -- the payment is + // refused with "That payee cannot receive", as it is for a payee with + // no account in that currency, so nothing tells the two apart. (Should + // somebody register that address later, they become payable from the + // payee's own creation time; the owner chose the address.) + // Behind the customer's profile row: two of these at once would each + // count the rows before the other inserted and together pass the cap. + profiles.hold(owner); + Payee known = payees.findFirstByOwnerAndUsername(owner, username); + if (known != null && !known.isRemoved()) { + throw Refused.with(409, "payee-already-list", "That payee is already on your list"); + } + if (known != null) { + // Back as it was: the same id and the same creation time, so + // whatever it was paid during its cooling-off still counts. + known.setRemoved(false); + known.setNickname(nickname(request.nickname(), username)); + payees.save(known); + audit.append(owner, "PAYEE_RESTORED", known.getId(), username); + return dto(known); + } + long now = System.currentTimeMillis(); + int live = 0; + int today = 0; + for (Payee p : payees.findByOwnerOrderByCreatedAtAsc(owner)) { + live += p.isRemoved() ? 0 : 1; + today += p.getCreatedAt().toEpochMilli() > now - DAY ? 1 : 0; + } + if (live >= MAX_PAYEES) { + throw Refused.with(409, "remove-payee-before-adding-another", "Remove a payee before adding another"); + } + if (today >= perDay) { + throw Refused.with(429, "too-many-new-payees", "You have added as many payees as a day allows; try again tomorrow"); + } + Payee payee = new Payee(); + payee.setId(Ids.next()); + payee.setOwner(owner); + payee.setUsername(username); + payee.setNickname(nickname(request.nickname(), username)); + payee.setCreatedAt(Instant.ofEpochMilli(now)); + payees.insert(payee); + audit.append(owner, "PAYEE_ADDED", payee.getId(), username); + return dto(payee); + } + + @Transactional(readOnly = true) + public List list(String owner) throws IOException { + List out = new ArrayList(); + for (Payee p : payees.findByOwnerOrderByCreatedAtAsc(owner)) { + if (!p.isRemoved()) { + out.add(dto(p)); + } + } + return out; + } + + /// `owner`'s payee `id`, or 404 whether it is missing, removed or someone + /// else's: a removed payee cannot be paid or renamed. + @Transactional(readOnly = true) + public Payee owned(String owner, String id) throws IOException { + Payee payee = id == null ? null : payees.findById(id); + if (payee == null || payee.isRemoved() || !payee.getOwner().equals(owner)) { + throw Refused.with(404, "no-such-payee", "No such payee"); + } + return payee; + } + + @Transactional(rollbackFor = Exception.class) + public PayeeDto rename(String owner, String id, NewPayeeDto request) throws IOException { + Payee payee = owned(owner, id); + // Only the nickname: pointing an existing payee at somebody else would + // skip the new payee's cooling-off and signature. + payee.setNickname(nickname(request == null ? null : request.nickname(), + payee.getUsername())); + payees.save(payee); + audit.append(owner, "PAYEE_RENAMED", id, payee.getNickname()); + return dto(payee); + } + + @Transactional(rollbackFor = Exception.class) + public PayeeDto remove(String owner, String id) throws IOException { + Payee payee = owned(owner, id); + payee.setRemoved(true); + payees.save(payee); + audit.append(owner, "PAYEE_REMOVED", id, payee.getUsername()); + return dto(payee); + } + + private PayeeDto dto(Payee p) { + return new PayeeDto(p.getId(), p.getUsername(), p.getNickname(), p.getCreatedAt().toEpochMilli(), + p.getCreatedAt().toEpochMilli() + money.coolOffMillis()); + } + + private static String nickname(String value, String fallback) { + String v = value == null ? "" : value.trim(); + if (v.length() == 0) { + v = fallback; + } + return v.length() <= 80 ? v : v.substring(0, 80); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/RequestSigning.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/RequestSigning.java new file mode 100644 index 00000000000..54b20b85e17 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/RequestSigning.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +import java.time.Instant; +import com.codenameone.examples.bastion.Refused; +import com.codename1.backend.Crypto; +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.SecurityExchange; +import com.codename1.backend.security.crypto.Der; +import com.codenameone.examples.bastion.Ids; +import com.codenameone.examples.bastion.api.SignedRequest; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.Device; +import com.codenameone.examples.bastion.domain.UsedNonce; +import com.codenameone.examples.bastion.repository.UsedNonceRepository; + +import java.io.IOException; + +/// Step-up by signature: proof that a request was made on a device the +/// customer registered, not merely by someone holding their access token. +/// +/// The scheme, in the `Bastion-Signature` header: +/// +/// ``` +/// v1;device=;ts=;nonce=<16-64 of [A-Za-z0-9_-]>;sig= +/// ``` +/// +/// `sig` is ES256 -- ECDSA over P-256 with SHA-256, r and s as two 32-byte +/// numbers side by side, as JOSE writes them -- by the device's private key, +/// of the UTF-8 bytes of +/// +/// ``` +/// METHOD|request target|lower-case hex sha256(body bytes)|nonce|ts +/// ``` +/// +/// What each part buys: +/// +/// - **The method, target and body hash** bind the signature to this one +/// request. A signature taken off a 10-dollar transfer cannot be put on a +/// 10,000-dollar one, or on another route. +/// - **The timestamp** must be within [#SKEW_MILLIS] of the server's clock, +/// so a captured request is worthless minutes later. +/// - **The nonce** is remembered until the timestamp would be refused anyway +/// and is accepted once, so a request captured and replayed within those +/// minutes is refused too. It is recorded in a transaction of its own, +/// before the money moves: a replay of a request that was *refused* -- +/// say, for want of funds -- must not succeed later when the funds arrive. +/// +/// Every refusal is the same 403 with a short reason and is audited, outside +/// the request's transaction so the record survives the refusal. +@Component +public class RequestSigning { + public static final String HEADER = SignedRequest.HEADER; + /// Two minutes either way. Phones' clocks are set by the network and are + /// rarely more than seconds out. + public static final long SKEW_MILLIS = SignedRequest.SKEW_MILLIS; + + private final Devices devices; + private final UsedNonceRepository nonces; + private final Audit audit; + + public RequestSigning(Devices devices, UsedNonceRepository nonces, Audit audit) { + this.devices = devices; + this.nonces = nonces; + this.audit = audit; + } + + /// Whether the current request carries a signature at all. + public static boolean present() { + HttpServer.Request request = request(); + String header = request == null ? null : request.getHeader(HEADER); + return header != null && header.length() > 0; + } + + /// The device that signed the current request for `owner`, or a 403. Each + /// signature is good for one request: calling this twice for one request + /// refuses the second as a replay. + public Device require(String owner) throws IOException { + String why = check(owner); + if (why != null) { + audit.append(owner, "SIGNATURE_REFUSED", target(), why); + throw Refused.with(403, "needs-device-signature", "This needs a signature from your device: {0}", why); + } + return devices.live(owner, field(request().getHeader(HEADER), "device")); + } + + /// Why the current request's signature does not hold, or null when it does. + private String check(String owner) throws IOException { + HttpServer.Request request = request(); + String header = request == null ? null : request.getHeader(HEADER); + if (header == null || header.length() == 0) { + return "missing"; + } + if (!header.startsWith(SignedRequest.SCHEME + ";")) { + return "unknown scheme"; + } + String deviceId = field(header, "device"); + String ts = field(header, "ts"); + String nonce = field(header, "nonce"); + String sig = field(header, "sig"); + if (deviceId == null || ts == null || nonce == null || sig == null) { + return "malformed"; + } + Device device = devices.live(owner, deviceId); + if (device == null) { + return "unknown device"; + } + long at; + try { + at = Long.parseLong(ts); + } catch (NumberFormatException notANumber) { + return "malformed"; + } + long now = System.currentTimeMillis(); + if (at < now - SKEW_MILLIS || at > now + SKEW_MILLIS) { + return "clock skew"; + } + if (!SignedRequest.nonceShaped(nonce)) { + return "malformed"; + } + byte[] signature = Devices.decode(sig); + byte[] key = Devices.decode(device.getPublicKey()); + if (signature == null || signature.length != 64 || key == null) { + return "bad signature"; + } + byte[] body = request.getBodyBytes(); + // The form both sides read from shared: the app signs exactly this. + String signed = SignedRequest.canonical(request.getMethod(), request.getTarget(), + Crypto.sha256(body == null ? new byte[0] : body), + request.getHeader("Idempotency-Key"), nonce, at); + boolean valid; + try { + valid = Crypto.verify("ES256", key, signed.getBytes("UTF-8"), + Der.ecdsaJoseToDer(signature)); + } catch (IOException unusable) { + valid = false; + } + if (!valid) { + return "bad signature"; + } + if (!remember(Ids.sha256(deviceId + "|" + nonce), at + SKEW_MILLIS + 1000L, now)) { + return "replayed"; + } + return null; + } + + /// Records a nonce, answering false when it was already used. The unique + /// key decides, so two copies of one request arriving together cannot + /// both pass. + private boolean remember(String id, long expiresAt, long now) throws IOException { + try { + insert(id, expiresAt, now); + return true; + } catch (IOException | RuntimeException duplicate) { + if (nonceKnown(id)) { + return false; + } + throw duplicate; + } + } + + @Transactional(rollbackFor = Exception.class) + private void insert(String id, long expiresAt, long now) throws IOException { + if (nonces.findById(id) != null) { + throw new IllegalStateException("replayed"); + } + // Purged as it goes: the table never holds more than a few minutes' + // worth of signed requests. + nonces.purge(Instant.ofEpochMilli(now)); + UsedNonce used = new UsedNonce(); + used.setId(id); + used.setExpiresAt(Instant.ofEpochMilli(expiresAt)); + nonces.insert(used); + } + + /// Drops the nonces whose signatures are past the allowed clock skew, on a + /// timer as well as on each insert: a server that stops receiving signed + /// requests would otherwise keep the last batch for good. + @Scheduled(fixedRateString = "${bastion.nonce.purge-ms:60000}") + @Transactional(rollbackFor = Exception.class) + public void purgeExpired() throws IOException { + nonces.purge(Instant.now()); + } + + @Transactional(readOnly = true) + private boolean nonceKnown(String id) throws IOException { + return nonces.findById(id) != null; + } + + static String field(String header, String name) { + return SignedRequest.field(header, name); + } + + private static HttpServer.Request request() { + SecurityExchange exchange = SecurityExchange.current(); + return exchange == null ? null : exchange.getRequest(); + } + + private static String target() { + HttpServer.Request request = request(); + return request == null ? "" : request.getMethod() + " " + request.getTarget(); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/SimulatedIntegrityVerifier.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/SimulatedIntegrityVerifier.java new file mode 100644 index 00000000000..f84d6e8c065 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/SimulatedIntegrityVerifier.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +/// Takes the device's word for it, which is exactly what a real verifier +/// must never do: a rooted phone reports whatever its owner -- or the malware +/// on it -- likes. +/// +/// It exists so the rest of the policy can be built and tested before the app +/// is signed by the build daemon and a real verdict can be had. One rule it +/// keeps from the real thing: no token, no attestation. A device that sent +/// nothing to check is never counted as attested, whatever it claims. +public class SimulatedIntegrityVerifier implements IntegrityVerifier { + /// The simulated verifier on a development profile; anywhere else a + /// startup failure, because trusting the client's own claims in + /// production would make the device policy decorative. + public static IntegrityVerifier forProfile(boolean development) { + if (!development) { + throw new IllegalStateException("SimulatedIntegrityVerifier trusts the device's own " + + "claims and runs only on a development profile. Declare an " + + "IntegrityVerifier bean that verifies Play Integrity / App Attest " + + "server side."); + } + return new SimulatedIntegrityVerifier(); + } + + @Override + public Verdict verify(String platform, String token, boolean reportedRooted, + boolean reportedAttested) { + boolean hasToken = token != null && token.length() > 0; + return new Verdict(reportedAttested && hasToken && !reportedRooted, reportedRooted); + } +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AssetRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AssetRepository.java new file mode 100644 index 00000000000..f5f82e780aa --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AssetRepository.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.Asset; + +import java.util.List; + +/// The assets the ledger knows. Seeded by V2__ledger.sql. +@Repository(entity = Asset.class, key = String.class) +public interface AssetRepository extends CrudRepository { + List findByEnabledTrueOrderByCodeAsc(); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AuditEventRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AuditEventRepository.java new file mode 100644 index 00000000000..71836ceb9be --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AuditEventRepository.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.AuditEvent; + +import java.util.List; + +/// The audit chain. +@Repository(entity = AuditEvent.class, key = Long.class) +public interface AuditEventRepository extends CrudRepository { + List findTop100ByActorOrderBySeqDesc(String actor); + + @Query("select e from AuditEvent e where e.seq > :after order by e.seq asc") + List after(@Param("after") long after); + + /// One page of the chain, for walking it without loading it whole. + List findTop500BySeqGreaterThanOrderBySeqAsc(long seq); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AuditHeadRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AuditHeadRepository.java new file mode 100644 index 00000000000..1e9463b6ef5 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AuditHeadRepository.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.AuditHead; + +/// The tail of the audit chain. +@Repository(entity = AuditHead.class, key = Integer.class) +public interface AuditHeadRepository extends CrudRepository { + Integer ONLY = Integer.valueOf(1); + + /// Writes the row without changing it, which holds it until the + /// transaction ends. + @Modifying + @Query("update AuditHead h set h.seq = h.seq where h.id = 1") + int hold(); + + @Modifying + @Query("update AuditHead h set h.seq = :seq, h.hash = :hash where h.id = 1") + int advance(@Param("seq") long seq, @Param("hash") String hash); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/CardRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/CardRepository.java new file mode 100644 index 00000000000..53cfdf0e56d --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/CardRepository.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.Card; + +import java.util.List; + +/// Cards. Freezing is one conditional update, so it takes effect for the next +/// authorization whatever is in flight. +@Repository(entity = Card.class, key = String.class) +public interface CardRepository extends CrudRepository { + List findByOwnerOrderByCreatedAtAsc(String owner); + + Card findFirstByAccountId(String accountId); + + @Modifying + @Query("update Card c set c.frozen = :frozen where c.id = :id and c.owner = :owner") + int setFrozen(@Param("id") String id, @Param("owner") String owner, + @Param("frozen") boolean frozen); + + @Modifying + @Query("update Card c set c.dailyLimit = :limit where c.id = :id and c.owner = :owner") + int setLimit(@Param("id") String id, @Param("owner") String owner, + @Param("limit") long limit); + + /// Writes the row without changing it, holding it until the transaction + /// ends: a spend reads the card's state behind it, so a freeze or a new + /// limit committed meanwhile is the one it sees. + @Modifying + @Query("update Card c set c.frozen = c.frozen where c.id = :id") + int hold(@Param("id") String id); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/ChainCursorRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/ChainCursorRepository.java new file mode 100644 index 00000000000..e3e44207fac --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/ChainCursorRepository.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import java.time.Instant; +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.ChainCursor; + + +@Repository(entity = ChainCursor.class, key = String.class) +public interface ChainCursorRepository extends CrudRepository { + /// Takes or renews the worker lease: succeeds (1) when `holder` already + /// has it or the last holder has not renewed since `staleBefore`. One + /// statement, so two servers racing for it cannot both win. + @Modifying + @Query("update ChainCursor c set c.lastHash = :holder, c.updatedAt = :now " + + "where c.chain = :chain and (c.lastHash = :holder or c.updatedAt < :staleBefore)") + int lease(@Param("chain") String chain, @Param("holder") String holder, + @Param("now") Instant now, @Param("staleBefore") Instant staleBefore); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/ChainTransferRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/ChainTransferRepository.java new file mode 100644 index 00000000000..5535d58edea --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/ChainTransferRepository.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import java.time.Instant; +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.ChainTransfer; + +import java.util.List; + +@Repository(entity = ChainTransfer.class, key = String.class) +public interface ChainTransferRepository extends CrudRepository { + List findByStatusOrderByCreatedAtAsc(String status); + + List findByOwnerOrderByCreatedAtDesc(String owner); + + List findByDirectionAndTxHash(String direction, String txHash); + + /// Records a signature only over the state it was made from. Two workers + /// that both signed one QUEUED row get 1 and 0: the second signature is + /// dropped before it is ever broadcast, so one withdrawal can never + /// become two transactions. + @Modifying + @Query("update ChainTransfer t set t.status = :next, t.nonce = :nonce, t.raw = :raw, " + + "t.txHash = :txHash, t.amount = :amount, t.updatedAt = :now " + + "where t.id = :id and t.status = :expected") + int signed(@Param("id") String id, @Param("expected") String expected, + @Param("next") String next, @Param("nonce") long nonce, @Param("raw") String raw, + @Param("txHash") String txHash, @Param("amount") long amount, @Param("now") Instant now); + + /// Moves a row from `expected` to `next`, or answers 0 if another worker + /// moved it first. + @Modifying + @Query("update ChainTransfer t set t.status = :next, t.updatedAt = :now " + + "where t.id = :id and t.status = :expected") + int advance(@Param("id") String id, @Param("expected") String expected, + @Param("next") String next, @Param("now") Instant now); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/CryptoAddressRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/CryptoAddressRepository.java new file mode 100644 index 00000000000..5bda51fe16d --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/CryptoAddressRepository.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.CryptoAddress; + +import java.util.List; + +@Repository(entity = CryptoAddress.class, key = String.class) +public interface CryptoAddressRepository extends CrudRepository { + List findByOwnerOrderByCreatedAtAsc(String owner); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/DepositAddressRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/DepositAddressRepository.java new file mode 100644 index 00000000000..72438974783 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/DepositAddressRepository.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.DepositAddress; + +import java.util.List; + +@Repository(entity = DepositAddress.class, key = String.class) +public interface DepositAddressRepository extends CrudRepository { + List findByOwnerAndAsset(String owner, String asset); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/DeviceRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/DeviceRepository.java new file mode 100644 index 00000000000..adc68660147 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/DeviceRepository.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.Device; + +import java.util.List; + +/// The devices allowed to sign for a customer. +@Repository(entity = Device.class, key = String.class) +public interface DeviceRepository extends CrudRepository { + List findByOwnerOrderByCreatedAtAsc(String owner); + + @Modifying + @Query("update Device d set d.revoked = TRUE where d.id = :id and d.owner = :owner") + int revoke(@Param("id") String id, @Param("owner") String owner); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/JournalEntryRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/JournalEntryRepository.java new file mode 100644 index 00000000000..4649be2486c --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/JournalEntryRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.JournalEntry; + +/// Movements of value, found again by the key their request carried. +@Repository(entity = JournalEntry.class, key = String.class) +public interface JournalEntryRepository extends CrudRepository { + JournalEntry findFirstByIdempotencyKey(String idempotencyKey); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/LedgerAccountRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/LedgerAccountRepository.java new file mode 100644 index 00000000000..8944e8ee5ee --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/LedgerAccountRepository.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.LedgerAccount; + +import java.util.List; + +/// Balances. The two updates are the only way a balance changes, and both are +/// a single conditional statement: the database checks and applies them +/// atomically, whatever else is running, so no read-modify-write window exists +/// for two transfers to race through. +@Repository(entity = LedgerAccount.class, key = String.class) +public interface LedgerAccountRepository extends CrudRepository { + List findByOwnerOrderByCreatedAtAsc(String owner); + + LedgerAccount findFirstByOwnerAndAssetAndKind(String owner, String asset, String kind); + + /// Every account in `asset`: what the chain's reserve check adds up. + List findByAsset(String asset); + + /// Adds `delta` (which may be negative) unless that would take the balance + /// below zero or the account is frozen. Answers 1 when applied, 0 when + /// refused. + @Modifying + @Query("update LedgerAccount a set a.balance = a.balance + :delta where a.id = :id " + + "and a.frozen = FALSE and a.balance + :delta >= 0") + int applyGuarded(@Param("id") String id, @Param("delta") long delta); + + /// The bank's own accounts: the other side of money arriving from and + /// leaving for the outside world, which is why they may go negative. A + /// negative SYS_CASH balance reads "this much has come in". + @Modifying + @Query("update LedgerAccount a set a.balance = a.balance + :delta where a.id = :id") + int applyUnguarded(@Param("id") String id, @Param("delta") long delta); + + /// 1 when account `id` is frozen now, 0 when not: read from the table, + /// not the session, whose copy may predate a freeze committed since. + /// A no-op write rather than a select, like the `hold` queries: an + /// UPDATE reads the latest committed row and locks it on SQLite, + /// PostgreSQL and MySQL alike, where a plain select under MySQL's + /// REPEATABLE READ would answer from the transaction's first snapshot. + @Modifying + @Query("update LedgerAccount a set a.frozen = a.frozen where a.id = :id and a.frozen = TRUE") + int frozenNow(@Param("id") String id); + + @Modifying + @Query("update LedgerAccount a set a.frozen = :frozen where a.id = :id") + int setFrozen(@Param("id") String id, @Param("frozen") boolean frozen); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/LoginFailureRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/LoginFailureRepository.java new file mode 100644 index 00000000000..2635ff3a43a --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/LoginFailureRepository.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import java.time.Instant; +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.LoginFailure; + +@Repository(entity = LoginFailure.class, key = String.class) +public interface LoginFailureRepository extends CrudRepository { + /// Starts a new window when the old one has run out. + @Modifying + @Query("update LoginFailure f set f.failures = 0, f.firstAt = :now " + + "where f.username = :username and f.firstAt < :cutoff") + int restartWindow(@Param("username") String username, @Param("now") Instant now, + @Param("cutoff") Instant cutoff); + + /// One more failure, counted by the database: two at once are two. + @Modifying + @Query("update LoginFailure f set f.failures = f.failures + 1 where f.username = :username") + int increment(@Param("username") String username); + + /// Locks the account when the count has reached `attempts`, and starts + /// over; 1 when this call locked it. + @Modifying + @Query("update LoginFailure f set f.lockedUntil = :until, f.failures = 0, f.firstAt = :now " + + "where f.username = :username and f.failures >= :attempts") + int lockIfReached(@Param("username") String username, @Param("attempts") int attempts, + @Param("now") Instant now, @Param("until") Instant until); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/MarketRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/MarketRepository.java new file mode 100644 index 00000000000..458a442e404 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/MarketRepository.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.Market; + +import java.util.List; + +@Repository(entity = Market.class, key = String.class) +public interface MarketRepository extends CrudRepository { + List findByBaseAndQuoteOrderByIdAsc(String base, String quote); + + /// Writes the row without changing it, holding it until the transaction + /// ends: the market's lock, as audit_head is the audit chain's. + @Modifying + @Query("update Market m set m.seq = m.seq where m.id = :id") + int hold(@Param("id") String id); + + @Modifying + @Query("update Market m set m.seq = m.seq + 1 where m.id = :id") + int advance(@Param("id") String id); + + @Modifying + @Query("update Market m set m.lastPrice = :price where m.id = :id") + int traded(@Param("id") String id, @Param("price") long price); + + @Modifying + @Query("update Market m set m.indexPrice = :price where m.id = :id") + int index(@Param("id") String id, @Param("price") long price); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/PayeeRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/PayeeRepository.java new file mode 100644 index 00000000000..411fec95bcf --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/PayeeRepository.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.Payee; + +import java.util.List; + +/// Payees, always read through their owner so one customer never sees +/// another's. +@Repository(entity = Payee.class, key = String.class) +public interface PayeeRepository extends CrudRepository { + List findByOwnerOrderByCreatedAtAsc(String owner); + + Payee findFirstByOwnerAndUsername(String owner, String username); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/PostingRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/PostingRepository.java new file mode 100644 index 00000000000..f1131bbec94 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/PostingRepository.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.Posting; + +import java.util.List; + +/// The legs of every entry. Only ever inserted. +@Repository(entity = Posting.class, key = String.class) +public interface PostingRepository extends CrudRepository { + List findByEntryId(String entryId); + + List findTop100ByAccountIdOrderByCreatedAtDescIdDesc(String accountId); + + /// What the postings say an account holds; null when it has none. + @Query("select sum(p.amount) from Posting p where p.accountId = :id") + Long total(@Param("id") String accountId); + + /// What all the postings in one asset add up to. Zero, always, or the + /// ledger is broken. + @Query("select sum(p.amount) from Posting p where p.asset = :asset") + Long assetTotal(@Param("asset") String asset); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/ProfileRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/ProfileRepository.java new file mode 100644 index 00000000000..4719330ab86 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/ProfileRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.Profile; + +@Repository(entity = Profile.class, key = String.class) +public interface ProfileRepository extends CrudRepository { + /// Writes the row without changing it, which holds it until the + /// transaction ends: what serializes two password changes of one account. + @Modifying + @Query("update Profile p set p.createdAt = p.createdAt where p.username = :username") + int hold(@Param("username") String username); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TradeOrderRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TradeOrderRepository.java new file mode 100644 index 00000000000..e7fd558cc4a --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TradeOrderRepository.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.TradeOrder; + +import java.util.List; + +@Repository(entity = TradeOrder.class, key = String.class) +public interface TradeOrderRepository extends CrudRepository { + TradeOrder findFirstByOwnerAndClientKey(String owner, String clientKey); + + List findByOwnerAndStatusOrderByCreatedAtDesc(String owner, String status); + + List findByMarketAndStatus(String market, String status); + + /// The sells a buy at `price` crosses, best (lowest) first, then oldest. + @Query("select o from TradeOrder o where o.market = :market and o.side = :side " + + "and o.status = :status and o.price <= :price order by o.price asc, o.seq asc") + List asks(@Param("market") String market, @Param("side") String side, + @Param("status") String status, @Param("price") long price); + + /// The buys a sell at `price` crosses, best (highest) first, then oldest. + @Query("select o from TradeOrder o where o.market = :market and o.side = :side " + + "and o.status = :status and o.price >= :price order by o.price desc, o.seq asc") + List bids(@Param("market") String market, @Param("side") String side, + @Param("status") String status, @Param("price") long price); + + /// The market of `owner`'s order `id`, or null. A projection, not the + /// row: an order loaded before its market's lock stays in the session + /// as it was read, and a re-read behind the lock would answer that copy + /// rather than what a concurrent fill committed. + @Query("select o.market from TradeOrder o where o.id = :id and o.owner = :owner") + String marketOf(@Param("id") String id, @Param("owner") String owner); + + @Query("select sum(o.held) from TradeOrder o where o.owner = :owner and o.status = :status " + + "and o.side = :side") + Long held(@Param("owner") String owner, @Param("status") String status, + @Param("side") String side); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TradeRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TradeRepository.java new file mode 100644 index 00000000000..f2f2d0320b8 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TradeRepository.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import java.time.Instant; +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.Trade; + +import java.util.List; + +@Repository(entity = Trade.class, key = String.class) +public interface TradeRepository extends CrudRepository { + @Query("select t from Trade t where t.buyer = :who or t.seller = :who " + + "order by t.createdAt desc") + List of(@Param("who") String who); + + /// `who`'s fills, on either side, since `since`: what the daily limit + /// counts of their trading. + @Query("select t from Trade t where (t.buyer = :who or t.seller = :who) " + + "and t.createdAt >= :since") + List since(@Param("who") String who, @Param("since") Instant since); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TransferRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TransferRepository.java new file mode 100644 index 00000000000..6d2b43fa2b4 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TransferRepository.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import java.time.Instant; +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.Transfer; + + +/// Movements as the limits count them. Only ever inserted. +@Repository(entity = Transfer.class, key = String.class) +public interface TransferRepository extends CrudRepository { + /// What `owner` has sent out of the bank or to someone else in `asset` + /// since `since`; null when nothing. + @Query("select sum(t.amount) from Transfer t where t.owner = :owner and t.asset = :asset " + + "and t.outgoing = TRUE and t.createdAt >= :since") + Long sentSince(@Param("owner") String owner, @Param("asset") String asset, + @Param("since") Instant since); + + /// What has gone through a payee or a card since `since`; null when nothing. + @Query("select sum(t.amount) from Transfer t where t.via = :via and t.createdAt >= :since") + Long throughSince(@Param("via") String via, @Param("since") Instant since); + + /// What has gone through a payee in `asset` since `since`; null when nothing. + @Query("select sum(t.amount) from Transfer t where t.via = :via and t.asset = :asset " + + "and t.createdAt >= :since") + Long throughSinceIn(@Param("via") String via, @Param("asset") String asset, + @Param("since") Instant since); +} diff --git a/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/UsedNonceRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/UsedNonceRepository.java new file mode 100644 index 00000000000..d90d9e22a0b --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/UsedNonceRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.repository; + +import java.time.Instant; +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.bastion.domain.UsedNonce; + + +/// Nonces signatures have used. Inserted, never updated; purged once expired. +@Repository(entity = UsedNonce.class, key = String.class) +public interface UsedNonceRepository extends CrudRepository { + @Modifying + @Query("delete from UsedNonce n where n.expiresAt < :now") + int purge(@Param("now") Instant now); +} diff --git a/scripts/bastion/backend/src/main/resources/db/migration/V2__ledger.sql b/scripts/bastion/backend/src/main/resources/db/migration/V2__ledger.sql new file mode 100644 index 00000000000..cdf2bb6ef29 --- /dev/null +++ b/scripts/bastion/backend/src/main/resources/db/migration/V2__ledger.sql @@ -0,0 +1,71 @@ +-- The ledger. Every script here is written once for SQLite, PostgreSQL and +-- MySQL: no generated keys, no native boolean or timestamp. A yes/no is a +-- SMALLINT 0/1 and a time is milliseconds since the epoch in a BIGINT. +-- +-- Money is a BIGINT count of an asset's smallest ledger unit, never a float: +-- cents for USD, satoshi for BTC. See asset.decimals. + +-- What can be held. Fiat and crypto are the same thing to the ledger: a code +-- and how many decimal places one unit of it is divided into. +CREATE TABLE asset ( + code VARCHAR(12) NOT NULL PRIMARY KEY, + name VARCHAR(60) NOT NULL, + kind VARCHAR(8) NOT NULL, + decimals INTEGER NOT NULL, + enabled SMALLINT NOT NULL +); + +-- A balance in one asset. Customers' accounts may never go below zero; the +-- bank's own (kind starting with SYS_) are the other side of every deposit and +-- withdrawal and may. `balance` is kept by the same transaction that writes +-- the postings, by a conditional update, and can always be recomputed from +-- them -- Ledger.verify does exactly that. +CREATE TABLE ledger_account ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + asset VARCHAR(12) NOT NULL, + kind VARCHAR(16) NOT NULL, + name VARCHAR(80) NOT NULL, + balance BIGINT NOT NULL, + frozen SMALLINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX ledger_account_owner ON ledger_account (owner, asset); + +-- One movement of value: a set of postings that sums to zero in every asset. +-- `idempotency_key` is unique, so the same request retried after a dropped +-- connection is answered with the entry it already made and moves nothing. +-- `request_hash` is what that request said, so a different request reusing +-- the key is refused rather than answered with somebody else's result. +CREATE TABLE journal_entry ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + idempotency_key VARCHAR(80) NOT NULL, + request_hash VARCHAR(64) NOT NULL, + kind VARCHAR(16) NOT NULL, + initiator VARCHAR(190) NOT NULL, + memo VARCHAR(140) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE UNIQUE INDEX journal_entry_key ON journal_entry (idempotency_key); + +-- One leg of an entry. Positive adds to the account, negative takes from it. +-- Rows are only ever inserted: a mistake is corrected by a reversing entry. +CREATE TABLE posting ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + entry_id VARCHAR(32) NOT NULL, + account_id VARCHAR(32) NOT NULL, + asset VARCHAR(12) NOT NULL, + amount BIGINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX posting_account ON posting (account_id, created_at); +CREATE INDEX posting_entry ON posting (entry_id); + +-- Assets 1 unit = 10^decimals ledger units. ETH has 18 decimals on its chain; +-- the ledger keeps 9 (gwei) so that a balance fits a BIGINT with room to +-- spare -- 9.2e18 wei is only 9.2 ETH. A bridge to a real chain rounds at the +-- edge, which is where a real exchange rounds too. +INSERT INTO asset (code, name, kind, decimals, enabled) VALUES ('USD', 'US Dollar', 'FIAT', 2, 1); +INSERT INTO asset (code, name, kind, decimals, enabled) VALUES ('EUR', 'Euro', 'FIAT', 2, 1); +INSERT INTO asset (code, name, kind, decimals, enabled) VALUES ('BTC', 'Bitcoin', 'CRYPTO', 8, 1); +INSERT INTO asset (code, name, kind, decimals, enabled) VALUES ('ETH', 'Ether', 'CRYPTO', 9, 1); diff --git a/scripts/bastion/backend/src/main/resources/db/migration/V3__audit.sql b/scripts/bastion/backend/src/main/resources/db/migration/V3__audit.sql new file mode 100644 index 00000000000..b075b35953f --- /dev/null +++ b/scripts/bastion/backend/src/main/resources/db/migration/V3__audit.sql @@ -0,0 +1,29 @@ +-- Everything that matters, in order, each row carrying the SHA-256 of the row +-- before it. Changing, deleting or reordering a row breaks every hash after +-- it, which Audit.verify reports by sequence number. A real bank also ships +-- the newest hash somewhere this database's administrators cannot write (a +-- WORM bucket, a transparency log), because whoever can rewrite this table +-- can rewrite the whole chain. +CREATE TABLE audit_event ( + seq BIGINT NOT NULL PRIMARY KEY, + at BIGINT NOT NULL, + actor VARCHAR(190) NOT NULL, + action VARCHAR(40) NOT NULL, + subject VARCHAR(190) NOT NULL, + detail VARCHAR(500) NOT NULL, + address VARCHAR(64) NOT NULL, + prev_hash VARCHAR(64) NOT NULL, + hash VARCHAR(64) NOT NULL +); +CREATE INDEX audit_event_actor ON audit_event (actor, seq); + +-- The newest link of the chain, in a row of its own. Appending updates it +-- first, which holds the row until the transaction ends: two appends never +-- read the same tail, on any engine, and an append rolled back with the +-- business change it records leaves no gap. +CREATE TABLE audit_head ( + id INTEGER NOT NULL PRIMARY KEY, + seq BIGINT NOT NULL, + hash VARCHAR(64) NOT NULL +); +INSERT INTO audit_head (id, seq, hash) VALUES (1, 0, '0000000000000000000000000000000000000000000000000000000000000000'); diff --git a/scripts/bastion/backend/src/main/resources/db/migration/V4__accounts.sql b/scripts/bastion/backend/src/main/resources/db/migration/V4__accounts.sql new file mode 100644 index 00000000000..05a508a4d6b --- /dev/null +++ b/scripts/bastion/backend/src/main/resources/db/migration/V4__accounts.sql @@ -0,0 +1,17 @@ +-- What the bank keeps of a customer beside the sign-in tables the security +-- layer creates (cn1_users, cn1_authorities, the token, authenticator-app and +-- passkey tables). Keyed by the same username. +CREATE TABLE profile ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + display_name VARCHAR(120) NOT NULL, + created_at BIGINT NOT NULL +); + +-- Wrong passwords per account, for the temporary lockout. A row exists only +-- for an account that exists, so guessing at made-up names cannot grow it. +CREATE TABLE login_failure ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + failures INTEGER NOT NULL, + first_at BIGINT NOT NULL, + locked_until BIGINT NOT NULL +); diff --git a/scripts/bastion/backend/src/main/resources/db/migration/V5__money.sql b/scripts/bastion/backend/src/main/resources/db/migration/V5__money.sql new file mode 100644 index 00000000000..1966de19351 --- /dev/null +++ b/scripts/bastion/backend/src/main/resources/db/migration/V5__money.sql @@ -0,0 +1,65 @@ +-- Money movement: who may be paid, the cards, the devices that sign for a +-- customer, the nonces those signatures have used, and one row per movement +-- the limits are counted from. + +CREATE TABLE payee ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + username VARCHAR(190) NOT NULL, + nickname VARCHAR(80) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE UNIQUE INDEX payee_owner_username ON payee (owner, username); + +-- Only the last four digits. The full number is the card processor's; a +-- database that never held it cannot leak it. +CREATE TABLE card ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + account_id VARCHAR(32) NOT NULL, + last4 VARCHAR(4) NOT NULL, + frozen SMALLINT NOT NULL, + daily_limit BIGINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE UNIQUE INDEX card_account ON card (account_id); + +CREATE TABLE device ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + public_key VARCHAR(400) NOT NULL, + platform VARCHAR(20) NOT NULL, + rooted SMALLINT NOT NULL, + attested SMALLINT NOT NULL, + revoked SMALLINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX device_owner ON device (owner); + +-- A nonce is kept until its signature could no longer pass the clock check, +-- then purged. The key is sha256(device|nonce), so the unique index is what +-- refuses a replay, atomically, however many copies arrive at once. +CREATE TABLE used_nonce ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + expires_at BIGINT NOT NULL +); +CREATE INDEX used_nonce_expiry ON used_nonce (expires_at); + +-- One row per movement, keyed by its journal entry: what the limits add up +-- and what a retried request's receipt is rebuilt from. `via` is the payee or +-- the card the money went through. +CREATE TABLE transfer ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + kind VARCHAR(16) NOT NULL, + from_account VARCHAR(32) NOT NULL, + to_account VARCHAR(32) NOT NULL, + via VARCHAR(32) NOT NULL, + asset VARCHAR(12) NOT NULL, + amount BIGINT NOT NULL, + outgoing SMALLINT NOT NULL, + memo VARCHAR(140) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX transfer_owner ON transfer (owner, created_at); +CREATE INDEX transfer_via ON transfer (via, created_at); diff --git a/scripts/bastion/backend/src/main/resources/db/migration/V6__exchange.sql b/scripts/bastion/backend/src/main/resources/db/migration/V6__exchange.sql new file mode 100644 index 00000000000..581f7d18a37 --- /dev/null +++ b/scripts/bastion/backend/src/main/resources/db/migration/V6__exchange.sql @@ -0,0 +1,74 @@ +-- The crypto exchange: markets, orders, trades, and the addresses coins may +-- be withdrawn to. The balances themselves are ordinary ledger accounts -- +-- an order's committed funds sit in the owner's HOLD account -- so nothing +-- here holds money; it records why the ledger moved it. + +-- One row per market, written from bastion.markets at start-up. Placing an +-- order writes this row first, which is the lock that makes one market match +-- one order at a time. +CREATE TABLE market ( + id VARCHAR(16) NOT NULL PRIMARY KEY, + base VARCHAR(12) NOT NULL, + quote VARCHAR(12) NOT NULL, + lot BIGINT NOT NULL, + index_price BIGINT NOT NULL, + last_price BIGINT NOT NULL, + seq BIGINT NOT NULL +); + +CREATE TABLE trade_order ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + market VARCHAR(16) NOT NULL, + side VARCHAR(4) NOT NULL, + type VARCHAR(6) NOT NULL, + price BIGINT NOT NULL, + quantity BIGINT NOT NULL, + budget BIGINT NOT NULL, + filled BIGINT NOT NULL, + notional BIGINT NOT NULL, + settled BIGINT NOT NULL, + fee BIGINT NOT NULL, + held BIGINT NOT NULL, + status VARCHAR(10) NOT NULL, + seq BIGINT NOT NULL, + client_key VARCHAR(80) NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); +-- The book: open orders of a market and side, by price then arrival. +CREATE INDEX trade_order_book ON trade_order (market, side, status, price, seq); +CREATE INDEX trade_order_owner ON trade_order (owner, status); +-- What makes a retried order a retry, however many copies arrive at once. +CREATE UNIQUE INDEX trade_order_key ON trade_order (owner, client_key); + +CREATE TABLE trade ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + market VARCHAR(16) NOT NULL, + buy_order VARCHAR(32) NOT NULL, + sell_order VARCHAR(32) NOT NULL, + buyer VARCHAR(190) NOT NULL, + seller VARCHAR(190) NOT NULL, + taker_side VARCHAR(4) NOT NULL, + price BIGINT NOT NULL, + quantity BIGINT NOT NULL, + buyer_paid BIGINT NOT NULL, + seller_got BIGINT NOT NULL, + fee BIGINT NOT NULL, + entry_id VARCHAR(32) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX trade_buyer ON trade (buyer, created_at); +CREATE INDEX trade_seller ON trade (seller, created_at); + +CREATE TABLE crypto_address ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + asset VARCHAR(12) NOT NULL, + address VARCHAR(100) NOT NULL, + label VARCHAR(80) NOT NULL, + usable_at BIGINT NOT NULL, + removed SMALLINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX crypto_address_owner ON crypto_address (owner); diff --git a/scripts/bastion/backend/src/main/resources/db/migration/V7__books.sql b/scripts/bastion/backend/src/main/resources/db/migration/V7__books.sql new file mode 100644 index 00000000000..a7e215da1f9 --- /dev/null +++ b/scripts/bastion/backend/src/main/resources/db/migration/V7__books.sql @@ -0,0 +1,55 @@ +-- The accounting layer over the ledger: reversals, closed periods and +-- four-eyes approvals. Portable across SQLite, PostgreSQL and MySQL like the +-- scripts before it. The chart of accounts is code (books.Chart), not a +-- table: an account's class follows from its kind and cannot drift. + +-- One row per reversed entry, keyed by the entry, so an entry is reversed at +-- most once even when two administrators try together: the second insert +-- fails on the key and its reversing entry rolls back with it. +CREATE TABLE reversal ( + entry_id VARCHAR(32) NOT NULL PRIMARY KEY, + reversed_by VARCHAR(32) NOT NULL, + at BIGINT NOT NULL +); + +-- Each closed period, in order. Nothing may be posted at or before the +-- newest closed_through; the closing entries that moved the period's revenue +-- and expense into retained earnings are listed in `entries`. +CREATE TABLE period_close ( + seq BIGINT NOT NULL PRIMARY KEY, + closed_through BIGINT NOT NULL, + closed_by VARCHAR(190) NOT NULL, + closed_at BIGINT NOT NULL, + entries VARCHAR(500) NOT NULL +); + +-- An administrator's adjustment or reversal. Above the threshold it waits, +-- PENDING, for a different administrator to approve it (maker-checker); +-- what it will post is fixed when it is made, so the checker approves exactly +-- what they saw. +CREATE TABLE approval ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + kind VARCHAR(16) NOT NULL, + status VARCHAR(10) NOT NULL, + maker VARCHAR(190) NOT NULL, + checker VARCHAR(190) NOT NULL, + account_id VARCHAR(32) NOT NULL, + entry_id VARCHAR(32) NOT NULL, + asset VARCHAR(12) NOT NULL, + amount BIGINT NOT NULL, + usd_cents BIGINT NOT NULL, + memo VARCHAR(140) NOT NULL, + request_key VARCHAR(80) NOT NULL, + result_entry VARCHAR(32) NOT NULL, + created_at BIGINT NOT NULL, + decided_at BIGINT NOT NULL +); +CREATE UNIQUE INDEX approval_key ON approval (request_key); +CREATE INDEX approval_status ON approval (status, created_at); + +-- The reports read by time. +CREATE INDEX posting_time ON posting (created_at); +CREATE INDEX journal_entry_time ON journal_entry (created_at); +CREATE INDEX trade_market_time ON trade (market, created_at); +CREATE INDEX audit_event_subject ON audit_event (subject, seq); +CREATE INDEX audit_event_action ON audit_event (action, seq); diff --git a/scripts/bastion/backend/src/main/resources/db/migration/V8__chain.sql b/scripts/bastion/backend/src/main/resources/db/migration/V8__chain.sql new file mode 100644 index 00000000000..76296a0be1a --- /dev/null +++ b/scripts/bastion/backend/src/main/resources/db/migration/V8__chain.sql @@ -0,0 +1,47 @@ +-- Coins crossing to and from a real blockchain (chain/). Empty and unused +-- unless bastion.chain.rpc-url names a node. + +CREATE TABLE chain_transfer ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + direction VARCHAR(3) NOT NULL, + owner VARCHAR(190) NOT NULL, + asset VARCHAR(12) NOT NULL, + address VARCHAR(42) NOT NULL, + amount BIGINT NOT NULL, + status VARCHAR(12) NOT NULL, + tx_hash VARCHAR(66) NOT NULL, + log_index INTEGER NOT NULL, + nonce BIGINT NOT NULL, + raw VARCHAR(4000) NOT NULL, + block_number BIGINT NOT NULL, + block_hash VARCHAR(66) NOT NULL, + fee BIGINT NOT NULL, + reference VARCHAR(32) NOT NULL, + detail VARCHAR(200) NOT NULL, + broadcast_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); +CREATE INDEX chain_transfer_owner ON chain_transfer (owner); +CREATE INDEX chain_transfer_status ON chain_transfer (status); +-- A deposit is credited once however often its block is scanned. A +-- withdrawal not yet signed holds "unsigned:" and its own id in tx_hash, so +-- it never collides. +CREATE UNIQUE INDEX chain_transfer_tx ON chain_transfer (direction, tx_hash, log_index); + +CREATE TABLE deposit_address ( + address VARCHAR(42) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + asset VARCHAR(12) NOT NULL, + hd_index BIGINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE UNIQUE INDEX deposit_address_index ON deposit_address (hd_index); +CREATE UNIQUE INDEX deposit_address_owner ON deposit_address (owner, asset); + +CREATE TABLE chain_cursor ( + chain VARCHAR(12) NOT NULL PRIMARY KEY, + next_block BIGINT NOT NULL, + last_hash VARCHAR(66) NOT NULL, + updated_at BIGINT NOT NULL +); diff --git a/scripts/bastion/backend/src/main/resources/db/migration/V9__payee_removed.sql b/scripts/bastion/backend/src/main/resources/db/migration/V9__payee_removed.sql new file mode 100644 index 00000000000..1867cb6c562 --- /dev/null +++ b/scripts/bastion/backend/src/main/resources/db/migration/V9__payee_removed.sql @@ -0,0 +1,5 @@ +-- A removed payee is kept, flagged, rather than deleted: its id is what the +-- cooling-off sums payments by, and its creation time is when that period +-- started. Deleting the row let a customer remove a payee and add it again +-- to start a fresh allowance; adding it again now restores this row. +ALTER TABLE payee ADD COLUMN removed SMALLINT DEFAULT 0 NOT NULL; diff --git a/scripts/bastion/backend/src/main/resources/db/migration/mysql/V1__collation.sql b/scripts/bastion/backend/src/main/resources/db/migration/mysql/V1__collation.sql new file mode 100644 index 00000000000..c8797764438 --- /dev/null +++ b/scripts/bastion/backend/src/main/resources/db/migration/mysql/V1__collation.sql @@ -0,0 +1,7 @@ +-- Makes this database's text case sensitive and NO PAD before any table +-- exists, so every table the later scripts create inherits it. The ORM needs +-- that of a text key, and a ledger needs it twice over: an idempotency key +-- "Ab" and "ab " are two different requests. MySQL and MariaDB share no name +-- for that collation, so each family reads its own executable comment; see +-- scripts/savor's V10__collation.sql for the long story. +ALTER DATABASE CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; diff --git a/scripts/bastion/backend/src/main/resources/db/migration/postgresql/V1__collation.sql b/scripts/bastion/backend/src/main/resources/db/migration/postgresql/V1__collation.sql new file mode 100644 index 00000000000..fdaec9c9161 --- /dev/null +++ b/scripts/bastion/backend/src/main/resources/db/migration/postgresql/V1__collation.sql @@ -0,0 +1,4 @@ +-- Nothing to do here: this engine's text is already case sensitive and NO +-- PAD. The version exists for mysql/V1__collation.sql, and every version +-- needs a script for each engine the server runs on. +SELECT 1; diff --git a/scripts/bastion/backend/src/main/resources/db/migration/sqlite/V1__collation.sql b/scripts/bastion/backend/src/main/resources/db/migration/sqlite/V1__collation.sql new file mode 100644 index 00000000000..fdaec9c9161 --- /dev/null +++ b/scripts/bastion/backend/src/main/resources/db/migration/sqlite/V1__collation.sql @@ -0,0 +1,4 @@ +-- Nothing to do here: this engine's text is already case sensitive and NO +-- PAD. The version exists for mysql/V1__collation.sql, and every version +-- needs a script for each engine the server runs on. +SELECT 1; diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AccessMatrixTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AccessMatrixTest.java new file mode 100644 index 00000000000..90fa36aad82 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AccessMatrixTest.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codename1.backend.test.MockResponse; +import com.codenameone.examples.bastion.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Who may reach what: the admin routes for admins only, the rest for anyone +/// signed in, and nothing for a stranger. +@BackendTest +class AccessMatrixTest { + private static final String[] ADMINS = { + "/api/admin/audit/verify", + "/api/admin/ledger/verify", + }; + private static final String[] SIGNED_IN = { + "/api/me", + }; + + @Autowired + private MockMvc mvc; + + private MockResponse answer(String path, String token) throws Exception { + MockRequestBuilder request = get(path).header("Host", SignIn.HOST); + if (token != null) { + request = request.header("Authorization", "Bearer " + token); + } + return mvc.perform(request).andReturn().getResponse(); + } + + @Test + void strangersAreAskedToSignIn() throws Exception { + for (String path : ADMINS) { + assertEquals(401, answer(path, null).getStatus(), path); + } + for (String path : SIGNED_IN) { + assertEquals(401, answer(path, null).getStatus(), path); + } + } + + @Test + void customersCannotReachTheAdminRoutes() throws Exception { + String token = SignIn.token(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + for (String path : ADMINS) { + assertEquals(403, answer(path, token).getStatus(), path); + } + for (String path : SIGNED_IN) { + assertEquals(200, answer(path, token).getStatus(), path); + } + } + + @Test + void adminsFindTheRecordsIntact() throws Exception { + String token = SignIn.token(mvc, DemoAccounts.ADMIN, DemoAccounts.PASSWORD); + for (String path : ADMINS) { + MockResponse answer = answer(path, token); + assertEquals(200, answer.getStatus(), path); + assertEquals(Boolean.TRUE, Json.parseObject(answer.getContentAsString()).get("intact"), + path + ": " + answer.getContentAsString()); + } + } + + @Test + void aForgedTokenIsRefused() throws Exception { + String token = SignIn.token(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + String forged = token.substring(0, token.length() - 4) + "AAAA"; + assertEquals(401, answer("/api/me", forged).getStatus()); + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AppAttestVerifierTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AppAttestVerifierTest.java new file mode 100644 index 00000000000..334ada9e440 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AppAttestVerifierTest.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.security.pki.Certificate; +import com.codenameone.examples.bastion.money.AppAttestVerifier; +import com.codenameone.examples.bastion.money.IntegrityVerifier; +import com.codenameone.examples.bastion.money.SimulatedIntegrityVerifier; +import java.util.Collections; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Synthetic App Attest attestations, made with openssl: a test root, an +/// intermediate and a credential certificate carrying the nonce extension, +/// in the CBOR attestation object the iOS port sends as +/// `cn1aa1:attest::`. The development AAGUID. +class AppAttestVerifierTest { + // The fixtures hash this exact id. It is assembled so the Initializr's + // project rename, which rewrites the app's name in every source, leaves + // it as the fixtures were made with. + private static final String APP_ID = "TEAMID1234.com.example." + "bas" + "tion"; + private static final String NONCE = "device-public-key"; + + private static final String TEST_ROOT = "-----BEGIN CERTIFICATE-----\n" + + "MIIBazCCARKgAwIBAgIUFCuN/C9j1v7YLkN/Q1iuOCHHn+AwCgYIKoZIzj0EAwIw\n" + + "EzERMA8GA1UEAwwIVGVzdFJvb3QwIBcNMjYxMDI1MTI1ODMzWhgPMjEyNjEwMDEx\n" + + "MjU4MzNaMBMxETAPBgNVBAMMCFRlc3RSb290MFkwEwYHKoZIzj0CAQYIKoZIzj0D\n" + + "AQcDQgAEgnY5dBqACvUFXzRRkdhg3tSUkPVaE8sUuhBKX+oaKdDVFQtdPW5lmc/h\n" + + "Qk/3K3dxm2JTyFLLyvy1dcABvx3m+qNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV\n" + + "HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJXaF+vRtzcaCq5pNsmxGeKGffTIMAoGCCqG\n" + + "SM49BAMCA0cAMEQCICtqDF0mBd0BN2ZcbgZI0r1bK/vpQ6aU8XSFPyRgAPbwAiBc\n" + + "8tfdJ3WjL9EkxZkJRuzMLz6SEGxdbYmfjGvoYrzS0g==\n" + + "-----END CERTIFICATE-----\n"; + + private static final String OTHER_ROOT = "-----BEGIN CERTIFICATE-----\n" + + "MIIBbjCCARSgAwIBAgIUUnE9PKarZYEmo83h1w19GCADFp8wCgYIKoZIzj0EAwIw\n" + + "FDESMBAGA1UEAwwJT3RoZXJSb290MCAXDTI2MTAyNTEyNTgzM1oYDzIxMjYxMDAx\n" + + "MTI1ODMzWjAUMRIwEAYDVQQDDAlPdGhlclJvb3QwWTATBgcqhkjOPQIBBggqhkjO\n" + + "PQMBBwNCAASYNd3bfGbybnhzwIsqCqaoPn4fvTKUtYZU07Cw70v2bWqgghokdVJY\n" + + "sYm5UYYyMZyvGW29gmS/6fPB63E0KvyEo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4G\n" + + "A1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUDrkXLKhtN+0As47lAhydXU+ZD40wCgYI\n" + + "KoZIzj0EAwIDSAAwRQIgZplx43JBWbGba7WI/MsWcS4opy+HZ5pRWZ5GGuw7bwcC\n" + + "IQDX9tYh24kHwW67nPbCkPax8I8nKCvSxbTsnupW7aooQg==\n" + + "-----END CERTIFICATE-----\n"; + + /// Counter 0: everything as Apple produces it. + private static final String GOOD = "cn1aa1:attest:SSKj1P4iTdvpXXp0K601Th5rNQ7ShK3megPft6I3Hhc=:o2NmbXRvYXBwbGUtYXBwY" + + "XR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAbEwggGtMIIBVKADAgECAhRSocUBeYildqff1mzq7VJAqxdyPDAKB" + + "ggqhkjOPQQDAjAUMRIwEAYDVQQDDAlUZXN0SW50ZXIwIBcNMjYxMDI1MTI1ODMzWhgPMjEyNjEwMDExM" + + "jU4MzNaMA8xDTALBgNVBAMMBGxlYWYwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARXFNjR/EiYc8Zgk" + + "mI+3xGvRYQHuJUMMnA3JP8GjQumltWHXFYUf1a99P9gs+Sznu/jLyo05QlEOwvAmFnfErOio4GGMIGDM" + + "AwGA1UdEwEB/wQCMAAwMwYJKoZIhvdjZAgCBCYwJKEiBCAjWAmklU6fZ7nJVr7S8LN0KHxTi6KpFXnEY" + + "uhtPujQhTAdBgNVHQ4EFgQUcY+7dxPyXNxrs4MGSZ2x4nY9JTYwHwYDVR0jBBgwFoAU8A72D3/cA89NF" + + "uuoUmf9nO2jcrkwCgYIKoZIzj0EAwIDRwAwRAIgc2I2drA0mn7Awxh7kTscXmqk4wpPLhPifMS7IY7TA" + + "ZMCIHyE0y1AbAd8YEGybGoixo9LwDG8KUw4h1ouc4TY5VjNWQGSMIIBjjCCATSgAwIBAgIUIVVPPbDFo" + + "TNSpWv7kU7FYTk3F6wwCgYIKoZIzj0EAwIwEzERMA8GA1UEAwwIVGVzdFJvb3QwIBcNMjYxMDI1MTI1O" + + "DMzWhgPMjEyNjEwMDExMjU4MzNaMBQxEjAQBgNVBAMMCVRlc3RJbnRlcjBZMBMGByqGSM49AgEGCCqGS" + + "M49AwEHA0IABOjPY1CQ0Brrys7QhtMbLHUAYjWz9CpukyGlV6IRtO9y5+aE89saVw5JQkzAXgIiSQ0vi" + + "zC9frm0zOwNITGY4WGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWB" + + "BTwDvYPf9wDz00W66hSZ/2c7aNyuTAfBgNVHSMEGDAWgBSV2hfr0bc3GgquaTbJsRnihn30yDAKBggqh" + + "kjOPQQDAgNIADBFAiEAvcbvxZXC4oMbX4Olq4X5kglvE1/95K+4WiXa91+Va/gCIDdt/RhWo6mLAAbX0" + + "SvhGU/LrnTTl85fm4V+VivRzIMkZ3JlY2VpcHRHcmVjZWlwdGhhdXRoRGF0YVhYW41yuv86eHAmJ3EKc" + + "RFuqMIRTAYW5JAo051065aUxeNBAAAAAGFwcGF0dGVzdGRldmVsb3AAIEkio9T+Ik3b6V16dCutNU4ea" + + "zUO0oSt5noD37eiNx4XoA=="; + + /// Counter 1, with a nonce extension that matches it, so only the counter is wrong. + private static final String COUNTER_ONE = "cn1aa1:attest:XG0vuIuoKuZ72DoGYnQ7l0vWTqbC7EfMhYT07oMes3k=:o2NmbXRvYXBwbGUtYXBwY" + + "XR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAbMwggGvMIIBVaADAgECAhRSocUBeYildqff1mzq7VJAqxdyPTAKB" + + "ggqhkjOPQQDAjAUMRIwEAYDVQQDDAlUZXN0SW50ZXIwIBcNMjYxMDI1MTI1ODMzWhgPMjEyNjEwMDExM" + + "jU4MzNaMBAxDjAMBgNVBAMMBWxlYWYyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnUnp/GgqgSPC+" + + "JmMKu0TxHvlxOO0DrN2+2sdy1iINom3KIliwbQ8pV6vYkjhkfugtcGnjLeuRgZiGewbp1c6o6OBhjCBg" + + "zAMBgNVHRMBAf8EAjAAMDMGCSqGSIb3Y2QIAgQmMCShIgQgf3qIXjuwWkQWtEsC65bxm81IruSV/BayB" + + "leLkIUP75cwHQYDVR0OBBYEFD12b6ZNMPegs86h63jzU852Ea3yMB8GA1UdIwQYMBaAFPAO9g9/3APPT" + + "RbrqFJn/Zzto3K5MAoGCCqGSM49BAMCA0gAMEUCIQCGy0O6TpEO8hh7K/6HzNtTdrwgqJu4Ft5neCVzg" + + "vGrCwIgGy6qIIoHiImNi67wlzTBlg/PSccoEt8op+cG3LA5s/5ZAZIwggGOMIIBNKADAgECAhQhVU89s" + + "MWhM1Kla/uRTsVhOTcXrDAKBggqhkjOPQQDAjATMREwDwYDVQQDDAhUZXN0Um9vdDAgFw0yNjEwMjUxM" + + "jU4MzNaGA8yMTI2MTAwMTEyNTgzM1owFDESMBAGA1UEAwwJVGVzdEludGVyMFkwEwYHKoZIzj0CAQYIK" + + "oZIzj0DAQcDQgAE6M9jUJDQGuvKztCG0xssdQBiNbP0Km6TIaVXohG073Ln5oTz2xpXDklCTMBeAiJJD" + + "S+LML1+ubTM7A0hMZjhYaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OB" + + "BYEFPAO9g9/3APPTRbrqFJn/Zzto3K5MB8GA1UdIwQYMBaAFJXaF+vRtzcaCq5pNsmxGeKGffTIMAoGC" + + "CqGSM49BAMCA0gAMEUCIQC9xu/FlcLigxtfg6WrhfmSCW8TX/3kr7haJdr3X5Vr+AIgN239GFajqYsAB" + + "tfRK+EZT8uudNOXzl+bhX5WK9HMgyRncmVjZWlwdEdyZWNlaXB0aGF1dGhEYXRhWFhbjXK6/zp4cCYnc" + + "QpxEW6owhFMBhbkkCjTnXTrlpTF40EAAAABYXBwYXR0ZXN0ZGV2ZWxvcAAgXG0vuIuoKuZ72DoGYnQ7l" + + "0vWTqbC7EfMhYT07oMes3mg"; + + private static AppAttestVerifier verifier(String root, String appId) throws Exception { + return new AppAttestVerifier(Collections.singletonList(Certificate.fromPem(root)), appId, + false, SimulatedIntegrityVerifier.forProfile(true)); + } + + private static boolean attested(AppAttestVerifier v, String token, String nonce) throws Exception { + return v.verify("ios", token, nonce, false, true).attested(); + } + + @Test + void acceptsAValidAttestation() throws Exception { + assertTrue(attested(verifier(TEST_ROOT, APP_ID), GOOD, NONCE)); + } + + @Test + void rejectsTheWrongNonce() throws Exception { + assertFalse(attested(verifier(TEST_ROOT, APP_ID), GOOD, "another-key")); + assertFalse(attested(verifier(TEST_ROOT, APP_ID), GOOD, null)); + } + + @Test + void rejectsTheWrongAppId() throws Exception { + assertFalse(attested(verifier(TEST_ROOT, "OTHERTEAM.com.example.bastion"), GOOD, NONCE)); + } + + @Test + void rejectsAnUntrustedRoot() throws Exception { + assertFalse(attested(verifier(OTHER_ROOT, APP_ID), GOOD, NONCE)); + assertFalse(attested(AppAttestVerifier.apple(APP_ID, false, + SimulatedIntegrityVerifier.forProfile(true)), GOOD, NONCE)); + } + + @Test + void rejectsANonZeroCounter() throws Exception { + assertFalse(attested(verifier(TEST_ROOT, APP_ID), COUNTER_ONE, NONCE)); + } + + @Test + void rejectsTheProductionAaguidMismatch() throws Exception { + AppAttestVerifier production = new AppAttestVerifier( + Collections.singletonList(Certificate.fromPem(TEST_ROOT)), APP_ID, true, + SimulatedIntegrityVerifier.forProfile(true)); + assertFalse(attested(production, GOOD, NONCE)); + } + + @Test + void rejectsMalformedTokensAndAssertions() throws Exception { + AppAttestVerifier v = verifier(TEST_ROOT, APP_ID); + assertFalse(attested(v, "cn1aa1:attest:AAAA:AAAA", NONCE)); + assertFalse(attested(v, "cn1aa1:assert:AAAA:AAAA:AAAA", NONCE)); + assertFalse(attested(v, GOOD.substring(0, GOOD.length() - 40), NONCE)); + } + + @Test + void otherPlatformsGoToTheDelegate() throws Exception { + IntegrityVerifier.Verdict verdict = verifier(TEST_ROOT, APP_ID) + .verify("android", "token", NONCE, false, true); + assertTrue(verdict.attested()); + assertNotNull(Certificate.fromPem(AppAttestVerifier.APPLE_APP_ATTESTATION_ROOT_CA)); + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AuthTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AuthTest.java new file mode 100644 index 00000000000..afae3ea812a --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AuthTest.java @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.DataSource; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockResponse; +import com.codename1.backend.security.webauthn.CredentialRecord; +import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntity; +import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntityRepository; +import com.codename1.backend.security.webauthn.UserCredentialRepository; +import com.codename1.security.Base32; +import com.codename1.security.Hash; +import com.codename1.security.Otp; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Ledger; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Signing in: tokens, the password policy, the lockout, the rate limits, the +/// second factor, and the audit record each leaves. +@BackendTest +class AuthTest { + private static final String PASSWORD = "correct horse battery"; + + @Autowired + private MockMvc mvc; + @Autowired + private Ledger ledger; + @Autowired + private Audit audit; + @Autowired + private DataSource db; + @Autowired + private UserCredentialRepository passkeys; + @Autowired + private PublicKeyCredentialUserEntityRepository passkeyHolders; + + private MockResponse call(String method, String path, String token, String json) + throws Exception { + com.codename1.backend.test.MockRequestBuilder request = "GET".equals(method) ? get(path) + : post(path).contentType("application/json").content(json == null ? "{}" : json); + request = request.header("Host", SignIn.HOST); + if (token != null) { + request = request.header("Authorization", "Bearer " + token); + } + return mvc.perform(request).andReturn().getResponse(); + } + + private long events(String actor, String action) throws Exception { + List rows = db.query("SELECT COUNT(*) AS n FROM audit_event WHERE actor = ? AND action = ?", + new Object[] {actor, action}); + return ((Number) ((Map) rows.get(0)).get("n")).longValue(); + } + + private static String totp(String secret, long offsetMillis) { + return Otp.totp(Base32.decode(secret), System.currentTimeMillis() + offsetMillis, 30, 6, + Hash.SHA1); + } + + @Test + void signInGivesShortTokensThatReadTheAccount() throws Exception { + String user = SignIn.register(mvc, "auth-token@bastion.test", PASSWORD); + Map tokens = SignIn.tokens(mvc, user, PASSWORD); + assertTrue(tokens.get("refresh_token") instanceof String, "a refresh token"); + assertTrue(((Number) tokens.get("expires_in")).longValue() <= 300, "five minutes"); + MockResponse me = call("GET", "/api/me", (String) tokens.get("access_token"), null); + assertEquals(200, me.getStatus()); + assertEquals(user, Json.parseObject(me.getContentAsString()).get("username")); + assertEquals("DENY", me.getHeader("X-Frame-Options")); + assertEquals("nosniff", me.getHeader("X-Content-Type-Options")); + assertTrue(me.getHeader("Cache-Control").indexOf("no-store") >= 0); + assertNotNull(me.getHeader("Content-Security-Policy")); + assertEquals(1, events(user, "SIGNIN")); + assertEquals(401, call("GET", "/api/me", null, null).getStatus()); + } + + @Test + void registeringOpensACheckingAccount() throws Exception { + String user = SignIn.register(mvc, "Auth-Open@Bastion.test", PASSWORD); + List held = ledger.accountsOf("auth-open@bastion.test"); + assertEquals(1, held.size(), "the address is stored folded: " + user); + assertEquals(AccountKinds.CHECKING, held.get(0).getKind()); + assertEquals("USD", held.get(0).getAsset()); + assertEquals(0, held.get(0).getBalance()); + assertEquals(1, events("auth-open@bastion.test", "REGISTERED")); + // Once only. + assertEquals(409, call("POST", "/api/account/register", null, + "{\"email\":\"auth-open@bastion.test\",\"password\":\"" + PASSWORD + + "\",\"displayName\":\"Again\"}").getStatus()); + } + + @Test + void weakPasswordsAreRefused() throws Exception { + assertEquals(400, call("POST", "/api/account/register", null, + "{\"email\":\"auth-weak@bastion.test\",\"password\":\"eleven-char\"," + + "\"displayName\":\"Weak\"}").getStatus()); + assertEquals(400, call("POST", "/api/account/register", null, + "{\"email\":\"auth-weak@bastion.test\",\"password\":\"auth-weak@bastion.test\"," + + "\"displayName\":\"Weak\"}").getStatus()); + } + + @Test + void wrongPasswordsAreAuditedAndLockTheAccount() throws Exception { + String user = SignIn.register(mvc, "auth-lock@bastion.test", PASSWORD); + for (int iter = 0; iter < 5; iter++) { + assertEquals(401, SignIn.password(mvc, user, "not the password!").getStatus()); + } + assertEquals(5, events(user, "SIGNIN_FAILED")); + assertEquals(1, events(user, "ACCOUNT_LOCKED")); + // Locked: the right password is not even looked at. + assertEquals(423, SignIn.password(mvc, user, PASSWORD).getStatus()); + assertEquals(1, events(user, "SIGNIN_LOCKED")); + // A name nobody has is recorded, and counts against nothing. + assertEquals(401, SignIn.password(mvc, "nobody@bastion.test", "whatever it is") + .getStatus()); + assertTrue(audit.verify().isEmpty(), "the chain holds"); + } + + @Test + void passwordChangesAreRateLimited() throws Exception { + String user = SignIn.register(mvc, "auth-limit@bastion.test", PASSWORD); + String token = SignIn.token(mvc, user, PASSWORD); + String wrong = "{\"current\":\"guess guess guess\",\"replacement\":\"a new long password\"}"; + // The current password is held to the sign-in lockout: five wrong + // ones lock the account, and then even the right one is refused. + String right = "{\"current\":\"" + PASSWORD + "\",\"replacement\":\"a new long password\"}"; + for (int iter = 0; iter < 10; iter++) { + assertEquals(iter < 5 ? 403 : 423, call("POST", "/api/account/password", token, + iter == 7 ? right : wrong).getStatus()); + } + assertEquals(429, call("POST", "/api/account/password", token, wrong).getStatus()); + assertEquals(10, events(user, "PASSWORD_CHANGE_REFUSED")); + } + + @Test + void passwordChangeWorksAndIsAudited() throws Exception { + String user = SignIn.register(mvc, "auth-change@bastion.test", PASSWORD); + String token = SignIn.token(mvc, user, PASSWORD); + assertEquals(200, call("POST", "/api/account/password", token, + "{\"current\":\"" + PASSWORD + "\",\"replacement\":\"a new long password\"}") + .getStatus()); + assertEquals(1, events(user, "PASSWORD_CHANGED")); + assertNull(SignIn.session(mvc, user, PASSWORD)); + assertNotNull(SignIn.session(mvc, user, "a new long password")); + } + + @Test + void anAuthenticatorAppMakesThePasswordNotEnough() throws Exception { + String user = SignIn.register(mvc, "auth-totp@bastion.test", PASSWORD); + String token = SignIn.token(mvc, user, PASSWORD); + // A password-only sign-in, however fresh, enrols an app only with + // the password: a stolen token must not put the thief's app on. + assertEquals(403, call("POST", "/api/account/mfa/totp", token, null).getStatus()); + assertEquals(403, call("POST", "/api/account/mfa/totp", token, + "{\"password\":\"not the password\"}").getStatus()); + assertEquals(2, events(user, "TOTP_REFUSED")); + String withPassword = "{\"password\":\"" + PASSWORD + "\"}"; + MockResponse started = call("POST", "/api/account/mfa/totp", token, withPassword); + assertEquals(200, started.getStatus()); + String secret = (String) Json.parseObject(started.getContentAsString()).get("secret"); + // The confirmation takes the password too: without it a good code is refused. + assertEquals(403, call("POST", "/api/account/mfa/totp/confirm", token, + "{\"code\":\"" + totp(secret, 0) + "\"}").getStatus()); + assertEquals(400, call("POST", "/api/account/mfa/totp/confirm", token, + "{\"code\":\"000000\",\"password\":\"" + PASSWORD + "\"}").getStatus()); + MockResponse confirmed = call("POST", "/api/account/mfa/totp/confirm", token, + "{\"code\":\"" + totp(secret, 0) + "\",\"password\":\"" + PASSWORD + "\"}"); + assertEquals(200, confirmed.getStatus(), confirmed.getContentAsString()); + assertEquals(10, ((List) Json.parseObject(confirmed.getContentAsString()).get("codes")) + .size()); + assertEquals(1, events(user, "TOTP_ENROLLED")); + // Starting over would turn the second factor off: refused. + assertEquals(409, call("POST", "/api/account/mfa/totp", token, withPassword).getStatus()); + + // The password alone: accepted, held back, and not signed in. + MockResponse first = SignIn.password(mvc, user, PASSWORD); + assertEquals(302, first.getStatus()); + assertTrue(first.getRedirectedUrl().indexOf("/login/mfa") >= 0, first.getRedirectedUrl()); + String pending = first.getCookie("CN1SESSION"); + assertNotNull(pending); + assertNull(SignIn.code(mvc, pending), "no code for a password alone"); + long before = events(user, "SIGNIN"); + + // A wrong code is recorded and signs nobody in. + MockResponse wrong = mvc.perform(SignIn.formPost("/login/mfa", "code", "123456") + .cookie("CN1SESSION", pending)).andReturn().getResponse(); + assertEquals(302, wrong.getStatus()); + + // The next step's code: the one confirmed is used up. + MockResponse second = mvc.perform(SignIn.formPost("/login/mfa", "code", + totp(secret, 30000)).cookie("CN1SESSION", pending)).andReturn().getResponse(); + assertEquals(200, second.getStatus(), second.getContentAsString()); + String session = second.getCookie("CN1SESSION"); + Map tokens = SignIn.exchange(mvc, session != null ? session : pending); + assertTrue(tokens.get("access_token") instanceof String); + assertEquals(before + 1, events(user, "SIGNIN")); + Map me = Json.parseObject(call("GET", "/api/me", (String) tokens.get("access_token"), null) + .getContentAsString()); + assertEquals(Boolean.TRUE, me.get("totpEnabled")); + assertFalse(audit.verify().size() > 0); + } + + @Test + void theStepUpPasswordIsHeldToTheLockout() throws Exception { + String user = SignIn.register(mvc, "auth-stepup@bastion.test", PASSWORD); + String token = SignIn.token(mvc, user, PASSWORD); + String right = "{\"password\":\"" + PASSWORD + "\"}"; + String wrong = "{\"password\":\"not the password\"}"; + // No password yet is a client that has not asked: never counted. + for (int iter = 0; iter < 6; iter++) { + assertEquals(403, call("POST", "/api/account/mfa/totp", token, null).getStatus()); + } + for (int iter = 0; iter < 4; iter++) { + assertEquals(401, SignIn.password(mvc, user, "not the password!").getStatus()); + } + // A right one wipes the four the form counted. + assertEquals(200, call("POST", "/api/account/mfa/totp", token, right).getStatus()); + for (int iter = 0; iter < 4; iter++) { + assertEquals(401, SignIn.password(mvc, user, "not the password!").getStatus()); + } + assertTrue(SignIn.password(mvc, user, PASSWORD).getStatus() != 423, "not locked"); + // A wrong one counts with the form's, and the lock then holds here too. + assertEquals(403, call("POST", "/api/account/mfa/totp", token, wrong).getStatus()); + for (int iter = 0; iter < 4; iter++) { + assertEquals(401, SignIn.password(mvc, user, "not the password!").getStatus()); + } + assertEquals(1, events(user, "ACCOUNT_LOCKED")); + MockResponse locked = call("POST", "/api/account/mfa/totp", token, right); + assertEquals(423, locked.getStatus()); + assertTrue(locked.getContentAsString().indexOf("account-locked") >= 0, + locked.getContentAsString()); + } + + /// Gives `user` a passkey the way a registration ceremony stores one; the + /// ceremony itself needs an authenticator, which a test does not have. + private void enrolPasskey(String user, byte id) { + PublicKeyCredentialUserEntity holder = passkeyHolders.findByUsername(user); + if (holder == null) { + holder = passkeyHolders.save(new PublicKeyCredentialUserEntity(user, + new byte[] {id, 1, 2, 3, 4, 5, 6, 7}, "Test Person")); + } + assertTrue(passkeys.save(CredentialRecord.builder() + .credentialId(new byte[] {id, 9, 8, 7, 6, 5, 4, 3, 2, 1}) + .userEntityUserId(holder.getId()).algorithm(-7).publicKey(new byte[] {1}) + .uvInitialized(true).label("Test key").created(System.currentTimeMillis()) + .build())); + } + + @Test + void aPasskeyRetiresThePassword() throws Exception { + String user = SignIn.register(mvc, "auth-passkey-only@bastion.test", PASSWORD); + assertEquals(200, SignIn.password(mvc, user, PASSWORD).getStatus()); + enrolPasskey(user, (byte) 41); + long refused = events(user, "SIGNIN_REFUSED"); + MockResponse answer = SignIn.password(mvc, user, PASSWORD); + assertEquals(403, answer.getStatus()); + assertTrue(answer.getContentAsString().indexOf("\"stronger\":\"passkey\"") >= 0, + answer.getContentAsString()); + assertNull(answer.getCookie("CN1SESSION") == null ? null + : SignIn.code(mvc, answer.getCookie("CN1SESSION")), "no code without the passkey"); + assertEquals(refused + 1, events(user, "SIGNIN_REFUSED")); + // Refused before the password is looked at: a wrong one is not counted. + assertEquals(403, SignIn.password(mvc, user, "not the password").getStatus()); + assertFalse(audit.verify().size() > 0); + } + + @Test + void aPasskeyAccountRecoversOnlyWithARecoveryCode() throws Exception { + String user = SignIn.register(mvc, "auth-passkey-recovery@bastion.test", PASSWORD); + String token = SignIn.token(mvc, user, PASSWORD); + String withPassword = "{\"password\":\"" + PASSWORD + "\"}"; + String secret = (String) Json.parseObject(call("POST", "/api/account/mfa/totp", token, + withPassword).getContentAsString()).get("secret"); + MockResponse confirmed = call("POST", "/api/account/mfa/totp/confirm", token, + "{\"code\":\"" + totp(secret, 0) + "\",\"password\":\"" + PASSWORD + "\"}"); + assertEquals(200, confirmed.getStatus(), confirmed.getContentAsString()); + List codes = (List) Json.parseObject(confirmed.getContentAsString()).get("codes"); + enrolPasskey(user, (byte) 42); + + // The password is still taken, on the way to the code step... + MockResponse first = SignIn.password(mvc, user, PASSWORD); + assertEquals(302, first.getStatus()); + String pending = first.getCookie("CN1SESSION"); + // ...where the authenticator app's code no longer signs in. + MockResponse app = mvc.perform(SignIn.formPost("/login/mfa", "code", + totp(secret, 30000)).cookie("CN1SESSION", pending)).andReturn().getResponse(); + assertEquals(403, app.getStatus()); + assertTrue(app.getContentAsString().indexOf("\"recovery\":true") >= 0, + app.getContentAsString()); + assertNull(SignIn.code(mvc, pending), "an app code is not the passkey"); + // A recovery code is the documented way back. + MockResponse recovered = mvc.perform(SignIn.formPost("/login/mfa", "code", + (String) codes.get(0)).cookie("CN1SESSION", pending)).andReturn().getResponse(); + assertEquals(200, recovered.getStatus(), recovered.getContentAsString()); + String session = recovered.getCookie("CN1SESSION"); + assertTrue(SignIn.exchange(mvc, session != null ? session : pending) + .get("access_token") instanceof String); + assertEquals(1, events(user, "SIGNIN_REFUSED")); + assertFalse(audit.verify().size() > 0); + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/BooksTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/BooksTest.java new file mode 100644 index 00000000000..7732f5d6d35 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/BooksTest.java @@ -0,0 +1,510 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.DataSource; +import com.codename1.backend.Json; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codename1.backend.test.MockResponse; +import com.codenameone.examples.bastion.account.DemoAccounts; +import com.codenameone.examples.bastion.api.AssetTotalDto; +import com.codenameone.examples.bastion.api.BreakDto; +import com.codenameone.examples.bastion.api.ChainReportDto; +import com.codenameone.examples.bastion.api.JournalEntryDto; +import com.codenameone.examples.bastion.api.JournalLegDto; +import com.codenameone.examples.bastion.api.LedgerLineDto; +import com.codenameone.examples.bastion.api.ReconciliationDto; +import com.codenameone.examples.bastion.api.TrialBalanceDto; +import com.codenameone.examples.bastion.api.TrialBalanceLineDto; +import com.codenameone.examples.bastion.api.VolumeDayDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.api.ApprovalDto; +import com.codenameone.examples.bastion.api.TAccountDto; +import com.codenameone.examples.bastion.books.Approvals; +import com.codenameone.examples.bastion.books.AuditTrailEndpoint; +import com.codenameone.examples.bastion.books.Books; +import com.codenameone.examples.bastion.domain.AuditEvent; +import com.codenameone.examples.bastion.domain.JournalEntry; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Leg; +import com.codenameone.examples.bastion.ledger.Ledger; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The accounting invariants: the trial balance balances, a closed period +/// is final, a reversal nets to zero, reconciliation finds a break, the +/// audit chain names its first broken link, an auditor cannot move money and +/// an adjustment above the threshold needs a second administrator. +@BackendTest +class BooksTest { + @Autowired + private Ledger ledger; + @Autowired + private Books books; + @Autowired + private Audit audit; + @Autowired + private DataSource db; + @Autowired + private MockMvc mvc; + @Autowired + private Approvals approvals; + + private static String key() { + return "test-" + TestKeys.random(); + } + + private LedgerAccount funded(String owner, long cents) throws Exception { + LedgerAccount account = ledger.open(owner, "USD", AccountKinds.CHECKING, "Checking"); + LedgerAccount cash = ledger.system("USD", AccountKinds.SYS_CASH); + ledger.post(key(), "DEPOSIT", owner, "seed", Arrays.asList( + new Leg(cash.getId(), -cents), new Leg(account.getId(), cents))); + return account; + } + + /// A maker is an account with a profile; one without waits for a checker. + private String maker(String username) throws Exception { + db.execute("INSERT INTO profile (username, display_name, created_at) VALUES (?, ?, ?)", + new Object[] {username, username, Long.valueOf(System.currentTimeMillis())}); + return username; + } + + private long balance(LedgerAccount a) throws Exception { + return ledger.owned(a.getOwner(), a.getId()).getBalance(); + } + + @Test + void theDemoHistoryBalancesInEveryAsset() throws Exception { + TrialBalanceDto tb = books.trialBalance(System.currentTimeMillis()); + assertTrue(tb.balanced()); + boolean usd = false; + for (AssetTotalDto t : tb.totals()) { + assertEquals(t.debits(), t.credits(), t.asset()); + usd |= "USD".equals(t.asset()) && t.debits() > 0; + } + assertTrue(usd, "the demo history posts USD"); + boolean liability = false; + for (TrialBalanceLineDto line : tb.lines()) { + // Customer deposits are the bank's liabilities: credit balances. + if ("2000".equals(line.code())) { + liability = true; + assertEquals(0L, line.debit()); + } + } + assertTrue(liability); + assertTrue(books.balanceSheet(System.currentTimeMillis()).balanced()); + // A week ago the books balanced too: every entry is balanced at its date. + assertTrue(books.trialBalance(System.currentTimeMillis() - 7 * Books.DAY).balanced()); + assertFalse(books.journal(0, System.currentTimeMillis(), 50).isEmpty()); + } + + @Test + void aReversalNetsToZeroAndHappensOnce() throws Exception { + LedgerAccount alice = funded("books-a1", 10000); + LedgerAccount bob = funded("books-b1", 0 + 1); + JournalEntry paid = ledger.post(key(), "TRANSFER", "books-a1", "rent", Arrays.asList( + new Leg(alice.getId(), -2500), new Leg(bob.getId(), 2500))); + JournalEntry back = ledger.reverse(key(), paid.getId(), "admin", "wrong payee"); + assertEquals(10000, balance(alice)); + assertEquals(1, balance(bob)); + JournalEntryDto original = books.entry(paid.getId()); + JournalEntryDto reversal = books.entry(back.getId()); + assertEquals(back.getId(), original.reversedBy()); + assertEquals(paid.getId(), reversal.reverses()); + long net = 0; + for (JournalLegDto leg : original.legs()) { + net += leg.credit() - leg.debit(); + } + for (JournalLegDto leg : reversal.legs()) { + net += leg.credit() - leg.debit(); + } + assertEquals(0L, net); + ResponseStatusException twice = assertThrows(ResponseStatusException.class, + () -> ledger.reverse(key(), paid.getId(), "admin", "")); + assertEquals(409, twice.getStatus()); + ResponseStatusException ofReversal = assertThrows(ResponseStatusException.class, + () -> ledger.reverse(key(), back.getId(), "admin", "")); + assertEquals(409, ofReversal.getStatus()); + } + + @Test + void reconciliationFindsAnInjectedBreak() throws Exception { + LedgerAccount alice = funded("books-a2", 5000); + assertTrue(books.reconcile().clean(), String.valueOf(books.reconcile().breaks())); + db.execute("UPDATE ledger_account SET balance = balance + 7 WHERE id = ?", + new Object[] {alice.getId()}); + try { + ReconciliationDto r = books.reconcile(); + assertFalse(r.clean()); + boolean cached = false; + boolean control = false; + for (BreakDto b : r.breaks()) { + cached |= "CACHED_BALANCE".equals(b.type()) && alice.getId().equals(b.reference()) + && b.difference() == 7; + control |= "CONTROL".equals(b.type()) && "2000".equals(b.reference()) + && b.difference() == 7; + } + assertTrue(cached && control, String.valueOf(r.breaks())); + } finally { + db.execute("UPDATE ledger_account SET balance = balance - 7 WHERE id = ?", + new Object[] {alice.getId()}); + } + assertTrue(books.reconcile().clean()); + } + + @Test + void escrowMovesAreNotVolume() throws Exception { + LedgerAccount alice = funded("books-v1", 5000); + LedgerAccount hold = ledger.open("books-v1", "USD", AccountKinds.HOLD, "Hold"); + long before = todayEntries(); + ledger.post(key(), "ORDER_HOLD", "books-v1", "BTC-USD BUY", Arrays.asList( + new Leg(alice.getId(), -700), new Leg(hold.getId(), 700))); + ledger.post(key(), "ORDER_RELEASE", "books-v1", "BTC-USD BUY", Arrays.asList( + new Leg(hold.getId(), -700), new Leg(alice.getId(), 700))); + assertEquals(before, todayEntries()); + LedgerAccount cash = ledger.system("USD", AccountKinds.SYS_CASH); + ledger.post(key(), "DEPOSIT", "books-v1", "real", Arrays.asList( + new Leg(cash.getId(), -10), new Leg(alice.getId(), 10))); + assertEquals(before + 1, todayEntries()); + } + + private long todayEntries() throws Exception { + long count = 0; + List days = books.volume(1); + for (VolumeDayDto d : days) { + if ("USD".equals(d.asset())) { + count += d.entries(); + } + } + return count; + } + + @Test + void theFirstBrokenLinkIsNamed() throws Exception { + audit.append("books-test", "TEST", "one", "first"); + AuditEvent victim = audit.append("books-test", "TEST", "two", "second"); + audit.append("books-test", "TEST", "three", "third"); + db.execute("UPDATE audit_event SET detail = ? WHERE seq = ?", + new Object[] {"rewritten", Long.valueOf(victim.getSeq())}); + try { + ChainReportDto report = audit.report(); + assertFalse(report.intact()); + assertEquals(victim.getSeq(), report.firstBrokenSeq()); + } finally { + db.execute("UPDATE audit_event SET detail = ? WHERE seq = ?", + new Object[] {"second", Long.valueOf(victim.getSeq())}); + } + ChainReportDto again = audit.report(); + assertTrue(again.intact(), String.valueOf(again.problems())); + assertEquals(0L, again.firstBrokenSeq()); + } + + @Test + void aClosedPeriodIsFinal() throws Exception { + LedgerAccount alice = funded("books-a3", 1000); + LedgerAccount cash = ledger.system("USD", AccountKinds.SYS_CASH); + long through = System.currentTimeMillis() - 60000L; + books.close("books-admin", through); + ResponseStatusException closed = assertThrows(ResponseStatusException.class, + () -> ledger.postAt(key(), "DEPOSIT", "books-a3", "late", Arrays.asList( + new Leg(cash.getId(), -10), new Leg(alice.getId(), 10)), through - 1)); + assertEquals(409, closed.getStatus()); + assertEquals(1000, balance(alice)); + ResponseStatusException again = assertThrows(ResponseStatusException.class, + () -> books.close("books-admin", through)); + assertEquals(400, again.getStatus()); + // Revenue and expense are zero at the close; their result is retained. + for (TrialBalanceLineDto line : books.trialBalance(through).lines()) { + if (line.code().startsWith("4") || line.code().startsWith("5")) { + assertEquals(0L, line.debit() + line.credit(), line.code() + " " + line.asset()); + } + } + assertTrue(books.trialBalance(through).balanced()); + assertTrue(books.balanceSheet(through).balanced()); + // The income statement still shows what the closed period earned. + assertFalse(books.incomeStatement(0, through).lines().isEmpty()); + // The open period takes postings as before. + ledger.post(key(), "DEPOSIT", "books-a3", "on time", Arrays.asList( + new Leg(cash.getId(), -10), new Leg(alice.getId(), 10))); + assertEquals(1010, balance(alice)); + } + + // ------------------------------------------------------------------ HTTP + + private MockResponse send(String token, MockRequestBuilder request) throws Exception { + return mvc.perform(request.header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)).andReturn().getResponse(); + } + + private static MockRequestBuilder json(String path, String body) { + return post(path).contentType("application/json").content(body); + } + + @Test + void anAuditorReadsEverythingAndMovesNothing() throws Exception { + String auditor = SignIn.token(mvc, DemoAccounts.AUDITOR, DemoAccounts.PASSWORD); + for (String path : new String[] {"/api/books/trial-balance", "/api/books/balance-sheet", + "/api/books/income-statement", "/api/books/journal?limit=5", "/api/books/chart", + "/api/books/reconciliation", "/api/books/volume?days=7", "/api/audit/chain", + "/api/audit/access-log", "/api/books/ledger?code=2000&asset=USD", + "/api/audit/evidence/trial-balance?format=csv"}) { + MockResponse answer = send(auditor, get(path)); + assertEquals(200, answer.getStatus(), path + " " + answer.getContentAsString()); + } + assertEquals(403, send(auditor, json("/api/admin/adjustments", + "{\"accountId\":\"x\",\"amount\":1,\"memo\":\"\",\"password\":\"" + + DemoAccounts.PASSWORD + "\"}").header("Idempotency-Key", key())).getStatus()); + assertEquals(403, send(auditor, json("/api/transfers", "{\"fromAccountId\":\"x\"," + + "\"toAccountId\":\"y\",\"amount\":1}").header("Idempotency-Key", key())) + .getStatus()); + assertEquals(403, send(auditor, get("/api/accounts")).getStatus()); + // The reads above are on the record, under the auditor's name. + MockResponse log = send(auditor, get("/api/audit/access-log?limit=50")); + assertTrue(log.getContentAsString().contains(DemoAccounts.AUDITOR)); + assertTrue(log.getContentAsString().contains("STAFF_READ")); + // And customers see none of it. + String customer = SignIn.token(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + assertEquals(403, send(customer, get("/api/books/trial-balance")).getStatus()); + assertEquals(403, send(customer, get("/api/audit/chain")).getStatus()); + } + + @Test + void aLargeAdjustmentNeedsASecondAdministrator() throws Exception { + LedgerAccount target = funded("books-c1", 100); + String admin = SignIn.token(mvc, DemoAccounts.ADMIN, DemoAccounts.PASSWORD); + String second = SignIn.token(mvc, DemoAccounts.SECOND_ADMIN, DemoAccounts.PASSWORD); + String body = "{\"accountId\":\"" + target.getId() + "\",\"amount\":500000," + + "\"memo\":\"goodwill\",\"password\":\"" + DemoAccounts.PASSWORD + "\"}"; + + MockResponse wrong = send(admin, json("/api/admin/adjustments", body.replace( + DemoAccounts.PASSWORD, "not-the-password")).header("Idempotency-Key", key())); + assertEquals(403, wrong.getStatus()); + + MockResponse made = send(admin, json("/api/admin/adjustments", body) + .header("Idempotency-Key", key())); + assertEquals(200, made.getStatus(), made.getContentAsString()); + Map approval = Json.parseObject(made.getContentAsString()); + assertEquals("PENDING", approval.get("status")); + assertEquals(100, balance(target)); + String approve = "/api/admin/approvals/" + approval.get("id") + "/approve"; + String decision = "{\"password\":\"" + DemoAccounts.PASSWORD + "\",\"reason\":\"ok\"}"; + + assertEquals(403, send(admin, json(approve, decision)).getStatus()); + assertEquals(100, balance(target)); + // The refusal is on the record: it is not rolled back with the request. + assertEquals(1, books.rows("SELECT seq FROM audit_event WHERE action = ? AND subject = ?", + new Object[] {"APPROVAL_REFUSED", approval.get("id")}).size()); + + MockResponse approved = send(second, json(approve, decision)); + assertEquals(200, approved.getStatus(), approved.getContentAsString()); + assertEquals("EXECUTED", Json.parseObject(approved.getContentAsString()).get("status")); + assertEquals(500100, balance(target)); + assertEquals(409, send(second, json(approve, decision)).getStatus()); + + // Below the threshold it posts at once, by one administrator. + MockResponse small = send(admin, json("/api/admin/adjustments", body.replace("500000", + "-50")).header("Idempotency-Key", key())); + assertEquals("EXECUTED", Json.parseObject(small.getContentAsString()).get("status")); + assertEquals(500050, balance(target)); + List trail = (List) Json.parse(send(admin, get("/api/audit/trail?subject=" + + approval.get("id"))).getContentAsString()); + assertTrue(trail.size() >= 2, String.valueOf(trail)); + } + + @Test + void customersGetTheirChartData() throws Exception { + String customer = SignIn.token(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + List accounts = (List) Json.parse(send(customer, get("/api/accounts")) + .getContentAsString()); + String id = (String) ((Map) accounts.get(0)).get("id"); + List history = (List) Json.parse(send(customer, get("/api/accounts/" + id + + "/balance-history?days=14")).getContentAsString()); + assertEquals(14, history.size()); + List candles = (List) Json.parse(send(customer, get( + "/api/exchange/markets/BTC-USD/candles?interval=3600000&limit=240")) + .getContentAsString()); + assertTrue(candles.size() > 24, "candles: " + candles.size()); + List flow = (List) Json.parse(send(customer, get("/api/insights/cashflow?days=10")) + .getContentAsString()); + assertEquals(10, flow.size()); + MockResponse allocation = send(customer, get("/api/insights/allocation")); + assertEquals(200, allocation.getStatus(), allocation.getContentAsString()); + assertEquals(404, send(customer, get("/api/accounts/nope/balance-history")).getStatus()); + } + + @Test + void anUnpricedAdjustmentWaitsForASecondAdministrator() throws Exception { + db.execute("INSERT INTO asset (code, name, kind, decimals, enabled) VALUES " + + "('TSTU', 'Unpriced test coin', 'CRYPTO', 8, 1)", new Object[0]); + LedgerAccount coin = ledger.open("books-u1", "TSTU", AccountKinds.WALLET, "Coins"); + // No market prices TSTU: the request cannot be valued, so it is not + // known to be small, and waits. + ApprovalDto a = approvals.adjust(maker("books-admin-u"), key(), coin.getId(), 1L, "unpriced"); + assertEquals(Approvals.PENDING, a.status()); + List rows = books.rows("SELECT detail FROM audit_event WHERE subject = ?", + new Object[] {a.id()}); + assertTrue(String.valueOf(rows).contains("unpriced"), String.valueOf(rows)); + } + + @Test + void splittingAnAdjustmentUnderTheThresholdStillNeedsAChecker() throws Exception { + LedgerAccount target = funded("books-s1", 100); + long under = approvals.threshold() - 1; + String admin = maker("books-admin-s"); + int executed = 0; + for (int i = 0; i < 10; i++) { + ApprovalDto a = approvals.adjust(admin, key(), target.getId(), under, + "split " + i); + executed += Approvals.EXECUTED.equals(a.status()) ? 1 : 0; + } + // The first fits the threshold; the rest add up past it in 24 hours. + assertEquals(1, executed); + assertEquals(100 + under, balance(target)); + } + + @Test + void concurrentSplitAdjustmentsStillNeedAChecker() throws Exception { + final LedgerAccount target = funded("books-c1", 100); + final long under = approvals.threshold() - 1; + final String admin = maker("books-admin-c"); + final AtomicInteger executed = new AtomicInteger(); + final AtomicInteger answered = new AtomicInteger(); + Runnable[] calls = new Runnable[8]; + for (int iter = 0; iter < calls.length; iter++) { + final int n = iter; + calls[iter] = () -> { + try { + ApprovalDto a = approvals.adjust(admin, key(), target.getId(), under, + "race " + n); + answered.incrementAndGet(); + if (Approvals.EXECUTED.equals(a.status())) { + executed.incrementAndGet(); + } + } catch (Exception busy) { + // A busy database rolls the request back: nothing posted, + // which the balance below confirms. + } + }; + } + // Holds the window between reading the 24h total and posting open, + // so the requests really overlap: without it the race is too narrow + // for a missing lock to show on PostgreSQL. + com.codenameone.examples.bastion.books.ApprovalsTestHooks.pauseAfterRecentTotal(() -> { + try { + Thread.sleep(100); + } catch (InterruptedException stop) { + Thread.currentThread().interrupt(); + } + }); + try { + Race.together(calls); + } finally { + com.codenameone.examples.bastion.books.ApprovalsTestHooks.pauseAfterRecentTotal(null); + } + // Each one read the 24h total under the maker's lock, so at most one + // threshold's worth posted without a checker. + assertTrue(answered.get() > 0, "no request was answered"); + assertTrue(executed.get() <= 1, executed.get() + " posted without a checker"); + assertEquals(100 + under * executed.get(), balance(target)); + assertEquals(executed.get(), books.rows("SELECT id FROM approval WHERE maker = ? " + + "AND status = ?", new Object[] {admin, Approvals.EXECUTED}).size()); + } + + @Test + void aMakerWithNoProfileWaitsForAChecker() throws Exception { + LedgerAccount target = funded("books-n1", 100); + ApprovalDto a = approvals.adjust("books-admin-none", key(), target.getId(), 1L, "x"); + assertEquals(Approvals.PENDING, a.status()); + } + + @Test + void aLongTAccountListsTheLatestAndTotalsEverything() throws Exception { + LedgerAccount many = funded("books-t1", 0 + 1); + LedgerAccount cash = ledger.system("USD", AccountKinds.SYS_CASH); + int extra = Books.LEDGER_LINES + 5; + for (int i = 0; i < extra; i++) { + ledger.post(key(), "DEPOSIT", "books-t1", "drip", Arrays.asList( + new Leg(cash.getId(), -1), new Leg(many.getId(), 1))); + } + TAccountDto t = books.tAccount(many.getId(), 0L, System.currentTimeMillis()); + assertTrue(t.truncated()); + assertEquals(Books.LEDGER_LINES, t.debits().size() + t.credits().size()); + assertEquals(1L + extra, t.totalCredits()); + assertEquals(balance(many), t.closingBalance()); + List credits = t.credits(); + assertEquals(balance(many), credits.get(credits.size() - 1).balance()); + } + + @Test + void theJournalEvidenceHoldsEveryEntry() throws Exception { + String admin = SignIn.token(mvc, DemoAccounts.ADMIN, DemoAccounts.PASSWORD); + long before = System.currentTimeMillis(); + MockResponse r = send(admin, get("/api/audit/evidence/journal?format=json&asOf=" + + before)); + assertEquals(200, r.getStatus(), r.getContentAsString()); + String content = (String) Json.parseObject(r.getContentAsString()).get("content"); + HashSet ids = new HashSet(); + for (Object row : (List) Json.parse(content)) { + ids.add(((Map) row).get("entry_id")); + } + long expected = ((Number) books.rows("SELECT COUNT(*) AS n FROM journal_entry " + + "WHERE created_at <= ?", new Object[] {Long.valueOf(before)}).get(0) + .get("n")).longValue(); + assertEquals(expected, ids.size()); + } + + @Test + void csvCellsThatASpreadsheetWouldRunAreQuoted() { + Map row = new LinkedHashMap(); + row.put("a", "\tcmd"); + row.put("b", "\r=1"); + row.put("c", "plain"); + String out = AuditTrailEndpoint.csv(Arrays.asList(row)); + assertTrue(out.contains("'\tcmd"), out); + assertTrue(out.contains("\"'\r=1\""), out); + assertTrue(out.contains(",plain"), out); + } + + @Test + void readingTheClosedPeriodsIsOnTheRecord() throws Exception { + String auditor = SignIn.token(mvc, DemoAccounts.AUDITOR, DemoAccounts.PASSWORD); + assertEquals(200, send(auditor, get("/api/books/periods")).getStatus()); + assertFalse(books.rows("SELECT seq FROM audit_event WHERE action = ? AND subject = ? " + + "AND actor = ?", new Object[] {"STAFF_READ", "periods", DemoAccounts.AUDITOR}) + .isEmpty()); + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/ExchangeTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/ExchangeTest.java new file mode 100644 index 00000000000..dd44f37a79a --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/ExchangeTest.java @@ -0,0 +1,423 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codenameone.examples.bastion.api.BookDto; +import com.codenameone.examples.bastion.api.NewOrderDto; +import com.codenameone.examples.bastion.api.OrderDto; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.exchange.Exchange; +import com.codenameone.examples.bastion.exchange.Markets; +import com.codenameone.examples.bastion.exchange.ExchangeEndpoint; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codenameone.examples.bastion.ledger.Leg; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/// The matching engine against the ledger. Each test trades on a market of +/// its own (`T1-USD` .. `T9-USD`, configured in application-test.properties, +/// all BTC/USD at a lot of 1,000 satoshi) so one test's resting orders never +/// meet another's. +/// +/// Prices are cents per whole bitcoin: 6,000,000 is 60,000.00. A quantity of +/// 100,000 satoshi (0.001 BTC) at that price is 6,000 cents, and the 0.2% +/// taker fee on it is 12. +@BackendTest +class ExchangeTest { + static final long PRICE = 6000000L; + static final long QTY = 100000L; + + @Autowired + private Exchange exchange; + + @Autowired + private ExchangeEndpoint endpoint; + + @Autowired + private Ledger ledger; + + @Autowired + private Markets markets; + + @Autowired + private Audit audit; + + + private final List owners = Collections.synchronizedList(new ArrayList()); + + /// A customer with `cents` in a checking account and `sats` in a wallet. + private String trader(long cents, long sats) throws Exception { + String owner = "x-" + TestKeys.random().substring(0, 12) + "@bastion.example"; + owners.add(owner); + LedgerAccount usd = ledger.open(owner, "USD", AccountKinds.CHECKING, "c"); + LedgerAccount btc = ledger.open(owner, "BTC", AccountKinds.WALLET, "w"); + if (cents > 0) { + fund(usd, cents); + } + if (sats > 0) { + fund(btc, sats); + } + return owner; + } + + private void fund(LedgerAccount to, long amount) throws Exception { + LedgerAccount cash = ledger.system(to.getAsset(), AccountKinds.SYS_CASH); + ledger.post(key(), "DEPOSIT", to.getOwner(), "", Arrays.asList( + new Leg(cash.getId(), -amount), new Leg(to.getId(), amount))); + } + + private long balance(String owner, String asset, String kind) throws Exception { + for (LedgerAccount a : ledger.accountsOf(owner)) { + if (a.getAsset().equals(asset) && a.getKind().equals(kind)) { + return a.getBalance(); + } + } + return 0L; + } + + private OrderDto limit(String owner, String market, String side, long price, long qty) + throws Exception { + return exchange.place(owner, key(), new NewOrderDto(market, side, Exchange.LIMIT, price, + qty, 0L)); + } + + private String status(OrderDto o) throws Exception { + return exchange.order(o.id()).getStatus(); + } + + private static String key() { + return TestKeys.random(); + } + + private void booksBalance() throws Exception { + List all = new ArrayList(owners); + all.add(AccountKinds.BANK); + assertEquals(new ArrayList(), ledger.verify(all)); + assertEquals(new ArrayList(), audit.verify()); + for (String owner : all) { + // What sits in a HOLD account is exactly what open orders hold. + assertEquals(exchange.held(owner, Exchange.BUY), + balance(owner, "USD", AccountKinds.HOLD), owner + " USD hold"); + assertEquals(exchange.held(owner, Exchange.SELL), + balance(owner, "BTC", AccountKinds.HOLD), owner + " BTC hold"); + } + } + + @Test + void aLimitPriceOutsideTheCollarIsRefused() throws Exception { + String buyer = trader(10000000, 0); + // 10% either side of the 60,000.00 index. + assertStatus(400, () -> limit(buyer, "T10-USD", Exchange.BUY, 6600001L, QTY)); + assertStatus(400, () -> limit(buyer, "T10-USD", Exchange.SELL, 5399999L, QTY)); + assertEquals(Exchange.OPEN, status(limit(buyer, "T10-USD", Exchange.BUY, 6600000L, + QTY))); + booksBalance(); + } + + @Test + void aMarketOrderStopsAtTheCollar() throws Exception { + String seller = trader(0, 2 * QTY); + String buyer = trader(10000000, 0); + OrderDto near = limit(seller, "T11-USD", Exchange.SELL, 6000000L, QTY); + OrderDto far = limit(seller, "T11-USD", Exchange.SELL, 6500000L, QTY); + // The index falls to 58,000.00: the collar now ends at 63,800.00, and + // the ask at 65,000.00 is past it. + markets.index("T11-USD", 5800000L); + try { + OrderDto buy = exchange.place(buyer, key(), new NewOrderDto("T11-USD", Exchange.BUY, + Exchange.MARKET, 0L, 0L, 5000000L)); + assertEquals(QTY, buy.filled(), "only the ask inside the collar"); + assertEquals(Exchange.FILLED, status(near)); + assertEquals(Exchange.OPEN, status(far)); + } finally { + markets.index("T11-USD", 6000000L); + } + booksBalance(); + } + + @Test + void aLimitOrderRestsThenFills() throws Exception { + String seller = trader(0, QTY); + String buyer = trader(100000, 0); + OrderDto ask = limit(seller, "T1-USD", Exchange.SELL, PRICE, QTY); + assertEquals(Exchange.OPEN, ask.status()); + assertEquals(0, balance(seller, "BTC", AccountKinds.WALLET)); + assertEquals(QTY, balance(seller, "BTC", AccountKinds.HOLD)); + BookDto book = endpoint.book("T1-USD"); + assertEquals(1, book.asks().size()); + assertEquals(QTY, book.asks().get(0).quantity()); + + OrderDto bid = limit(buyer, "T1-USD", Exchange.BUY, PRICE, QTY); + assertEquals(Exchange.FILLED, bid.status()); + assertEquals(Exchange.FILLED, status(ask)); + assertEquals(100000 - 6000 - 12, balance(buyer, "USD", AccountKinds.CHECKING)); + assertEquals(QTY, balance(buyer, "BTC", AccountKinds.WALLET)); + assertEquals(6000, balance(seller, "USD", AccountKinds.CHECKING)); + assertEquals(0, balance(seller, "BTC", AccountKinds.HOLD)); + assertEquals(0, balance(buyer, "USD", AccountKinds.HOLD)); + assertTrue(endpoint.book("T1-USD").asks().isEmpty()); + booksBalance(); + } + + @Test + void aPartialFillLeavesTheRestResting() throws Exception { + String seller = trader(0, 3 * QTY); + String buyer = trader(100000, 0); + OrderDto ask = limit(seller, "T2-USD", Exchange.SELL, PRICE, 3 * QTY); + // Bids above the ask: trades at the ask, and the difference comes back. + OrderDto bid = limit(buyer, "T2-USD", Exchange.BUY, 6100000L, QTY); + assertEquals(Exchange.FILLED, bid.status()); + assertEquals(100000 - 6012, balance(buyer, "USD", AccountKinds.CHECKING)); + assertEquals(0, balance(buyer, "USD", AccountKinds.HOLD)); + assertEquals(Exchange.OPEN, status(ask)); + assertEquals(QTY, exchange.order(ask.id()).getFilled()); + assertEquals(2 * QTY, balance(seller, "BTC", AccountKinds.HOLD)); + assertEquals(2 * QTY, endpoint.book("T2-USD").asks().get(0).quantity()); + booksBalance(); + } + + @Test + void priceThenTimePriority() throws Exception { + String first = trader(0, QTY); + String second = trader(0, QTY); + String better = trader(0, QTY); + String buyer = trader(100000, 0); + OrderDto a = limit(first, "T3-USD", Exchange.SELL, PRICE, QTY); + OrderDto b = limit(second, "T3-USD", Exchange.SELL, PRICE, QTY); + OrderDto c = limit(better, "T3-USD", Exchange.SELL, PRICE - 10000L, QTY); + limit(buyer, "T3-USD", Exchange.BUY, PRICE, 2 * QTY); + // The better price first though it came last, then the older of the + // two at the same price. + assertEquals(Exchange.FILLED, status(c)); + assertEquals(Exchange.FILLED, status(a)); + assertEquals(Exchange.OPEN, status(b)); + assertEquals(0, exchange.order(b.id()).getFilled()); + assertEquals(5990, balance(better, "USD", AccountKinds.CHECKING)); + booksBalance(); + } + + @Test + void cancellingReleasesTheHold() throws Exception { + String buyer = trader(100000, 0); + OrderDto bid = limit(buyer, "T4-USD", Exchange.BUY, 5500000L, QTY); + // 5,000 at the limit and the most fee it could pay, 10. + assertEquals(5511, balance(buyer, "USD", AccountKinds.HOLD)); + assertEquals(100000 - 5511, balance(buyer, "USD", AccountKinds.CHECKING)); + String stranger = trader(0, 0); + assertStatus(404, () -> exchange.cancel(stranger, bid.id())); + OrderDto cancelled = exchange.cancel(buyer, bid.id()); + assertEquals(Exchange.CANCELLED, cancelled.status()); + assertEquals(100000, balance(buyer, "USD", AccountKinds.CHECKING)); + assertEquals(0, balance(buyer, "USD", AccountKinds.HOLD)); + assertStatus(409, () -> exchange.cancel(buyer, bid.id())); + booksBalance(); + } + + @Test + void aMarketBuyLeavesNoHold() throws Exception { + String seller = trader(0, 2 * QTY); + String buyer = trader(100000, 0); + limit(seller, "T5-USD", Exchange.SELL, PRICE, QTY); + limit(seller, "T5-USD", Exchange.SELL, 6100000L, QTY); + OrderDto buy = exchange.place(buyer, key(), new NewOrderDto("T5-USD", Exchange.BUY, + Exchange.MARKET, 0L, 0L, 10000L)); + assertEquals(Exchange.FILLED, buy.status()); + assertEquals(0, balance(buyer, "USD", AccountKinds.HOLD)); + long spent = 100000 - balance(buyer, "USD", AccountKinds.CHECKING); + long bought = balance(buyer, "BTC", AccountKinds.WALLET); + assertTrue(spent <= 10000 && spent > 9900, "spent " + spent); + // All of the first level, then whole lots of the second. + assertTrue(bought > QTY && bought < 2 * QTY && bought % 1000 == 0, "bought " + bought); + // The rest of the book, then a market buy into an empty book: it buys + // nothing and keeps nothing. + String late = trader(50000, 0); + exchange.place(late, key(), new NewOrderDto("T5-USD", Exchange.BUY, Exchange.MARKET, + 0L, 0L, 40000L)); + assertEquals(0, balance(late, "USD", AccountKinds.HOLD)); + assertTrue(endpoint.book("T5-USD").asks().isEmpty()); + String last = trader(50000, 0); + OrderDto none = exchange.place(last, key(), new NewOrderDto("T5-USD", Exchange.BUY, + Exchange.MARKET, 0L, 0L, 40000L)); + assertEquals(50000, balance(last, "USD", AccountKinds.CHECKING)); + assertEquals(0, balance(last, "USD", AccountKinds.HOLD)); + assertEquals(Exchange.CANCELLED, none.status()); + booksBalance(); + } + + @Test + void anOrderNeverTradesWithItsOwner() throws Exception { + String a = trader(100000, QTY); + OrderDto ask = limit(a, "T6-USD", Exchange.SELL, PRICE, QTY); + OrderDto bid = limit(a, "T6-USD", Exchange.BUY, PRICE, QTY); + assertEquals(Exchange.CANCELLED, status(ask)); + assertEquals(Exchange.OPEN, bid.status()); + assertEquals(0, bid.filled()); + assertEquals(QTY, balance(a, "BTC", AccountKinds.WALLET)); + assertTrue(endpoint.book("T6-USD").asks().isEmpty()); + booksBalance(); + } + + @Test + void anOrderNeedsTheFunds() throws Exception { + String poor = trader(1000, 500); + assertStatus(409, () -> limit(poor, "T7-USD", Exchange.BUY, PRICE, QTY)); + assertStatus(409, () -> limit(poor, "T7-USD", Exchange.SELL, PRICE, 1000L)); + assertStatus(400, () -> limit(poor, "T7-USD", Exchange.SELL, PRICE, 1500L)); + assertStatus(400, () -> limit(poor, "T7-USD", Exchange.BUY, Long.MAX_VALUE / 2, 1000000L)); + assertEquals(1000, balance(poor, "USD", AccountKinds.CHECKING)); + assertEquals(500, balance(poor, "BTC", AccountKinds.WALLET)); + assertTrue(exchange.open(poor).isEmpty()); + booksBalance(); + } + + @Test + void aRetriedOrderIsPlacedOnce() throws Exception { + String buyer = trader(100000, 0); + String k = key(); + NewOrderDto order = new NewOrderDto("T8-USD", Exchange.BUY, Exchange.LIMIT, 5500000L, + QTY, 0L); + OrderDto first = exchange.place(buyer, k, order); + OrderDto again = exchange.place(buyer, k, order); + assertEquals(first.id(), again.id()); + assertEquals(5511, balance(buyer, "USD", AccountKinds.HOLD)); + assertStatus(422, () -> exchange.place(buyer, k, new NewOrderDto("T8-USD", Exchange.BUY, + Exchange.LIMIT, 5500000L, 2 * QTY, 0L))); + booksBalance(); + } + + /// Many customers placing and cancelling at once on one market. Whatever + /// interleaving the threads find, the books must balance after it: every + /// asset sums to zero, every balance is its postings, every hold is what + /// its open orders say, and the audit chain is whole. + @Test + void aTakerSellersFeeNeverExceedsTheProceeds() throws Exception { + // One lot at 1,000.00 USD a coin is a single cent; at 950.00 it is + // 0.95 of one, which rounds down to nothing alone. The taker's fee is + // on its running total, so a fill can pay a cent of fee out of a cent + // of proceeds -- but never more than the fill brought in. + String buyer = trader(100000, 0); + String seller = trader(0, 6 * 1000L); + limit(buyer, "T9-USD", Exchange.BUY, 100000L, 1000L); + limit(buyer, "T9-USD", Exchange.BUY, 100000L, 1000L); + limit(buyer, "T9-USD", Exchange.BUY, 95000L, 1000L); + limit(buyer, "T9-USD", Exchange.BUY, 95000L, 1000L); + limit(buyer, "T9-USD", Exchange.BUY, 95000L, 1000L); + limit(buyer, "T9-USD", Exchange.BUY, 95000L, 1000L); + OrderDto sell = limit(seller, "T9-USD", Exchange.SELL, 95000L, 6 * 1000L); + assertEquals(Exchange.FILLED, sell.status()); + // 2 + 4 x 0.95 = 5.8, so 5 cents of proceeds, a cent of fee on them. + assertEquals(4, balance(seller, "USD", AccountKinds.CHECKING)); + assertTrue(exchange.order(sell.id()).getFee() <= 5L); + booksBalance(); + } + + @Test + void theBooksBalanceAfterAnOrderStorm() throws Exception { + final String[] traders = new String[6]; + for (int i = 0; i < traders.length; i++) { + traders[i] = trader(5000000, 5000000); + } + final List unexpected = Collections.synchronizedList(new ArrayList()); + final AtomicInteger placed = new AtomicInteger(); + Runnable[] calls = new Runnable[8]; + for (int t = 0; t < calls.length; t++) { + final long seed = 1000L + t; + calls[t] = () -> { + Random r = new Random(seed); + List mine = new ArrayList(); + for (int n = 0; n < 25; n++) { + String owner = traders[r.nextInt(traders.length)]; + try { + if (!mine.isEmpty() && r.nextInt(4) == 0) { + String id = mine.remove(r.nextInt(mine.size())); + exchange.cancel(exchange.order(id).getOwner(), id); + continue; + } + boolean buy = r.nextBoolean(); + String side = buy ? Exchange.BUY : Exchange.SELL; + long qty = (1 + r.nextInt(50)) * 1000L; + OrderDto o; + if (r.nextInt(5) == 0) { + o = exchange.place(owner, key(), new NewOrderDto("STORM-USD", side, + Exchange.MARKET, 0L, buy ? 0L : qty, + buy ? 1000L + r.nextInt(5000) : 0L)); + } else { + long price = PRICE + (r.nextInt(21) - 10) * 1000L + r.nextInt(7); + o = limit(owner, "STORM-USD", side, price, qty); + } + placed.incrementAndGet(); + if (Exchange.OPEN.equals(o.status())) { + mine.add(o.id()); + } + } catch (ResponseStatusException refused) { + // 409: out of funds, or the order filled before its + // cancel. Either refusal moved nothing. + if (refused.getStatus() != 409) { + unexpected.add(refused); + } + } catch (Throwable busy) { + // A database too busy to take the write: the + // transaction rolled back whole, which the checks + // below confirm. + String text = String.valueOf(busy); + if (text.indexOf("busy") < 0 && text.indexOf("locked") < 0 + && text.indexOf("BUSY") < 0 && text.indexOf("LOCKED") < 0) { + unexpected.add(busy); + } + } + } + }; + } + Race.together(calls); + if (!unexpected.isEmpty()) { + fail("unexpected: " + unexpected.get(0), unexpected.get(0)); + } + assertTrue(placed.get() > 50, placed.get() + " orders placed"); + booksBalance(); + } + + private interface Call { + void run() throws Exception; + } + + private static void assertStatus(int status, Call call) throws Exception { + try { + call.run(); + } catch (ResponseStatusException answered) { + assertEquals(status, answered.getStatus(), answered.getReason()); + return; + } + fail("expected " + status); + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/LedgerTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/LedgerTest.java new file mode 100644 index 00000000000..9b451bde8b8 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/LedgerTest.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.DataSource; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codenameone.examples.bastion.audit.Audit; +import com.codenameone.examples.bastion.domain.JournalEntry; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Leg; +import com.codenameone.examples.bastion.ledger.Ledger; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The ledger's three rules, and the audit chain's one. +@BackendTest +class LedgerTest { + @Autowired + private Ledger ledger; + @Autowired + private Audit audit; + @Autowired + private DataSource db; + + private static String key() throws Exception { + return "test-" + Ids.next(); + } + + private LedgerAccount funded(String owner, long cents) throws Exception { + LedgerAccount account = ledger.open(owner, "USD", AccountKinds.CHECKING, "Checking"); + if (cents > 0) { + LedgerAccount cash = ledger.system("USD", AccountKinds.SYS_CASH); + ledger.post(key(), "DEPOSIT", owner, "seed", Arrays.asList( + new Leg(cash.getId(), -cents), new Leg(account.getId(), cents))); + } + return account; + } + + private long balance(LedgerAccount account) throws Exception { + return ledger.owned(account.getOwner(), account.getId()).getBalance(); + } + + private List books(String... owners) throws Exception { + List all = new ArrayList(Arrays.asList(owners)); + all.add(AccountKinds.BANK); + return ledger.verify(all); + } + + @Test + void aTransferMovesMoneyAndTheBooksBalance() throws Exception { + LedgerAccount alice = funded("ledger-a1", 10000); + LedgerAccount bob = funded("ledger-b1", 0); + ledger.post(key(), "TRANSFER", "ledger-a1", "rent", Arrays.asList( + new Leg(alice.getId(), -2500), new Leg(bob.getId(), 2500))); + assertEquals(7500, balance(alice)); + assertEquals(2500, balance(bob)); + assertEquals(new ArrayList(), books("ledger-a1", "ledger-b1")); + } + + @Test + void anUnbalancedEntryIsRefused() throws Exception { + LedgerAccount alice = funded("ledger-a2", 10000); + LedgerAccount bob = funded("ledger-b2", 0); + assertThrows(IllegalStateException.class, () -> ledger.post(key(), "TRANSFER", + "ledger-a2", "", Arrays.asList(new Leg(alice.getId(), -100), + new Leg(bob.getId(), 99)))); + assertEquals(10000, balance(alice)); + assertEquals(0, balance(bob)); + } + + @Test + void anOverdraftMovesNothingAtAll() throws Exception { + LedgerAccount alice = funded("ledger-a3", 1000); + LedgerAccount bob = funded("ledger-b3", 0); + ResponseStatusException refused = assertThrows(ResponseStatusException.class, + () -> ledger.post(key(), "TRANSFER", "ledger-a3", "", Arrays.asList( + new Leg(bob.getId(), 1001), new Leg(alice.getId(), -1001)))); + assertEquals(409, refused.getStatus()); + // bob's leg sorts first and was applied before alice's was refused; the + // rollback has to have taken it back. + assertEquals(0, balance(bob)); + assertEquals(1000, balance(alice)); + assertEquals(new ArrayList(), books("ledger-a3", "ledger-b3")); + } + + @Test + void aFrozenAccountNeitherPaysNorReceives() throws Exception { + LedgerAccount alice = funded("ledger-a4", 1000); + LedgerAccount bob = funded("ledger-b4", 0); + ledger.freeze(bob.getId(), true); + assertThrows(ResponseStatusException.class, () -> ledger.post(key(), "TRANSFER", + "ledger-a4", "", Arrays.asList(new Leg(alice.getId(), -10), + new Leg(bob.getId(), 10)))); + assertEquals(1000, balance(alice)); + } + + @Test + void aRetryMovesMoneyOnceAndAReusedKeyIsRefused() throws Exception { + LedgerAccount alice = funded("ledger-a5", 1000); + LedgerAccount bob = funded("ledger-b5", 0); + String key = key(); + List legs = Arrays.asList(new Leg(alice.getId(), -300), new Leg(bob.getId(), 300)); + JournalEntry first = ledger.post(key, "TRANSFER", "ledger-a5", "", legs); + JournalEntry again = ledger.post(key, "TRANSFER", "ledger-a5", "", legs); + assertEquals(first.getId(), again.getId()); + assertEquals(700, balance(alice)); + ResponseStatusException reused = assertThrows(ResponseStatusException.class, + () -> ledger.post(key, "TRANSFER", "ledger-a5", "", Arrays.asList( + new Leg(alice.getId(), -301), new Leg(bob.getId(), 301)))); + assertEquals(422, reused.getStatus()); + assertEquals(700, balance(alice)); + } + + @Test + void concurrentSpendsCannotOverdraw() throws Exception { + final LedgerAccount alice = funded("ledger-a6", 500); + final LedgerAccount bob = funded("ledger-b6", 0); + final AtomicInteger moved = new AtomicInteger(); + Runnable[] calls = new Runnable[8]; + for (int iter = 0; iter < calls.length; iter++) { + calls[iter] = () -> { + try { + ledger.post(key(), "TRANSFER", "ledger-a6", "", Arrays.asList( + new Leg(alice.getId(), -100), new Leg(bob.getId(), 100))); + moved.incrementAndGet(); + } catch (Exception refused) { + // Insufficient funds, or a busy database: either way + // nothing moved, which the balances below confirm. + } + }; + } + Race.together(calls); + assertTrue(moved.get() <= 5, moved.get() + " spends of 100 from 500"); + assertEquals(500 - 100L * moved.get(), balance(alice)); + assertEquals(100L * moved.get(), balance(bob)); + assertEquals(new ArrayList(), books("ledger-a6", "ledger-b6")); + } + + @Test + void theAuditChainNoticesAnEditedRow() throws Exception { + audit.append("audit-1", "TEST", "one", "first"); + audit.append("audit-1", "TEST", "two", "second"); + assertEquals(new ArrayList(), audit.verify()); + db.execute("UPDATE audit_event SET detail = 'rewritten' WHERE actor = ? AND subject = ?", + new Object[] {"audit-1", "one"}); + List problems = audit.verify(); + assertFalse(problems.isEmpty()); + assertTrue(problems.get(0).contains("was changed"), problems.toString()); + db.execute("UPDATE audit_event SET detail = 'first' WHERE actor = ? AND subject = ?", + new Object[] {"audit-1", "one"}); + assertEquals(new ArrayList(), audit.verify()); + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/MoneyTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/MoneyTest.java new file mode 100644 index 00000000000..726ce4c3d6e --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/MoneyTest.java @@ -0,0 +1,1036 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.Base64Url; +import com.codename1.backend.Crypto; +import com.codename1.backend.DataSource; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.security.webauthn.CredentialRecord; +import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntity; +import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntityRepository; +import com.codename1.backend.security.webauthn.UserCredentialRepository; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codename1.backend.test.MockResponse; +import com.codename1.backend.security.crypto.JwkSource; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtEncoder; +import com.codename1.backend.security.oauth2.jwt.JwtClaimsSet; +import com.codename1.backend.security.oauth2.jwt.JwtEncoderParameters; +import com.codenameone.examples.bastion.audit.Audit; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.delete; +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.request; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Money movement over HTTP, the way the app does it: idempotency keys on +/// every movement, a device key signing whatever needs a step-up, and every +/// limit the server sets. +/// +/// The device keys are made with the JDK here, as the app's secure key store +/// would make them on a phone; only the public half is ever sent. +@BackendTest +class MoneyTest { + static final String PASSWORD = "a long passphrase for money"; + + @Autowired + private MockMvc mvc; + + @Autowired + private UserCredentialRepository passkeys; + + @Autowired + private PublicKeyCredentialUserEntityRepository passkeyHolders; + + @Autowired + private JwkSource signingKeys; + + @Autowired + private DataSource db; + + @Autowired + private Audit audit; + + @Autowired + private com.codenameone.examples.bastion.ledger.Ledger ledger; + + @Autowired + private com.codenameone.examples.bastion.account.SignInGuard guard; + + /// A customer signed in, with a registered device. + private final class Customer { + final String name; + String token; + final String checking; + TestKeys key; + String device; + + Customer(boolean rooted) throws Exception { + name = SignIn.register(mvc, "m" + TestKeys.random().substring(0, 12) + + "@bastion.example", PASSWORD); + token = SignIn.token(mvc, name, PASSWORD); + checking = String.valueOf(((Map) accounts().get(0)).get("id")); + key = newKey(); + device = register(key, rooted); + } + + /// Adds `pair` as a device, from a sign-in with a second factor (the + /// only kind that may add one); the test goes on with its own token. + String register(TestKeys pair, boolean rooted) throws Exception { + String plain = token; + token = aged(plain, 0, java.util.Arrays.asList("pwd", "otp", "mfa")); + MockResponse answer; + try { + answer = send(json("/api/devices", "{\"publicKey\":\"" + + Base64Url.encode(pair.publicKey) + "\",\"platform\":\"and\"," + + "\"rooted\":" + rooted + ",\"attested\":true,\"integrityToken\":\"t\"," + + "\"password\":\"" + PASSWORD + "\"}")); + } finally { + token = plain; + } + assertEquals(200, answer.getStatus(), answer.getContentAsString()); + Map device = Json.parseObject(answer.getContentAsString()); + assertEquals(Boolean.valueOf(!rooted), device.get("trusted")); + return (String) device.get("id"); + } + + List accounts() throws Exception { + MockResponse answer = send(get("/api/accounts")); + assertEquals(200, answer.getStatus()); + return (List) Json.parse(answer.getContentAsString()); + } + + long balance(String account) throws Exception { + for (Object a : accounts()) { + if (account.equals(((Map) a).get("id"))) { + return ((Number) ((Map) a).get("balance")).longValue(); + } + } + throw new AssertionError("no account " + account); + } + + MockResponse send(MockRequestBuilder request) throws Exception { + return mvc.perform(request.header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)).andReturn().getResponse(); + } + + void deposit(String account, long amount) throws Exception { + MockResponse answer = send(json("/api/accounts/" + account + "/deposit", + "{\"amount\":" + amount + ",\"memo\":\"cash\"}").header("Idempotency-Key", key())); + assertEquals(200, answer.getStatus(), answer.getContentAsString()); + } + + String open(String asset, String kind) throws Exception { + MockResponse answer = send(json("/api/accounts", + "{\"asset\":\"" + asset + "\",\"kind\":\"" + kind + "\",\"name\":\"x\"}")); + assertEquals(200, answer.getStatus(), answer.getContentAsString()); + return (String) Json.parseObject(answer.getContentAsString()).get("id"); + } + + /// POSTs `body` to `path`, signed by `pair` as device `device`. + MockResponse signed(String path, String body, TestKeys pair, long ts, String nonce, + String idempotencyKey) throws Exception { + MockRequestBuilder request = json(path, body) + .header("Bastion-Signature", + signature("POST", path, body, pair, ts, nonce, idempotencyKey)); + if (idempotencyKey != null) { + request = request.header("Idempotency-Key", idempotencyKey); + } + return send(request); + } + + /// `method` `path` with `body` (may be empty), signed by this device. + MockResponse signedAs(String method, String path, String body) throws Exception { + MockRequestBuilder request = request(method, path).contentType("application/json") + .content(body).header("Bastion-Signature", signature(method, path, body, key, + System.currentTimeMillis(), nonce(), null)); + return send(request); + } + + MockResponse unsigned(String method, String path, String body) throws Exception { + return send(request(method, path).contentType("application/json").content(body)); + } + + MockResponse signed(String path, String body, String idempotencyKey) throws Exception { + return signed(path, body, key, System.currentTimeMillis(), nonce(), idempotencyKey); + } + + String signature(String method, String path, String body, TestKeys pair, long ts, + String nonce, String idempotencyKey) throws Exception { + String text = method + "|" + path + "|" + + Ids.hex(Crypto.sha256(body.getBytes("UTF-8"))) + + "|" + (idempotencyKey == null ? "" : idempotencyKey) + + "|" + nonce + "|" + ts; + byte[] jose = pair.sign(text.getBytes("UTF-8")); + return "v1;device=" + device + ";ts=" + ts + ";nonce=" + nonce + ";sig=" + + Base64Url.encode(jose); + } + + String addPayee(Customer other) throws Exception { + MockResponse answer = signed("/api/payees", "{\"email\":\"" + other.name + + "\",\"nickname\":\"friend\"}", null); + assertEquals(200, answer.getStatus(), answer.getContentAsString()); + return (String) Json.parseObject(answer.getContentAsString()).get("id"); + } + + String payBody(String payee, long amount) { + return "{\"fromAccountId\":\"" + checking + "\",\"payeeId\":\"" + payee + + "\",\"amount\":" + amount + ",\"memo\":\"rent\"}"; + } + } + + private static MockRequestBuilder json(String path, String body) { + return post(path).contentType("application/json").content(body); + } + + private static String key() { + return TestKeys.random(); + } + + private static String nonce() { + return TestKeys.random(); + } + + private static TestKeys newKey() { + return TestKeys.next(); + } + + private static String internal(String from, String to, long amount) { + return "{\"fromAccountId\":\"" + from + "\",\"toAccountId\":\"" + to + "\",\"amount\":" + + amount + ",\"memo\":\"save\"}"; + } + + @Test + void listsAndRemovesOnlyTheCallersPasskeys() throws Exception { + Customer c = new Customer(false); + Customer other = new Customer(false); + byte[] handle = TestKeys.random().getBytes("UTF-8"); + passkeyHolders.save(new PublicKeyCredentialUserEntity(c.name, handle, c.name)); + byte[] credentialId = TestKeys.random().getBytes("UTF-8"); + passkeys.save(CredentialRecord.builder().credentialId(credentialId) + .userEntityUserId(handle).algorithm(-7).publicKey(new byte[] {1}) + .label("phone").created(1).lastUsed(2).build()); + String id = Base64Url.encode(credentialId); + + List listed = (List) Json.parse(c.send(get("/api/account/passkeys")).getContentAsString()); + assertEquals(1, listed.size()); + assertEquals("phone", ((Map) listed.get(0)).get("label")); + assertEquals(id, ((Map) listed.get(0)).get("id")); + assertEquals(0, ((List) Json.parse(other.send(get("/api/account/passkeys")) + .getContentAsString())).size()); + + // A step-up, as revoking a device is: the token alone, or a wrong + // password, removes nothing. + String remove = "/api/account/passkeys/" + id + "/remove"; + String right = "{\"password\":\"" + PASSWORD + "\"}"; + assertEquals(404, other.unsigned("POST", remove, right).getStatus()); + assertEquals(403, c.unsigned("POST", remove, "{}").getStatus()); + assertEquals(403, c.unsigned("POST", remove, "{\"password\":\"not it\"}").getStatus()); + assertEquals(1, ((List) Json.parse(c.send(get("/api/account/passkeys")) + .getContentAsString())).size()); + MockResponse removed = c.unsigned("POST", remove, right); + assertEquals(200, removed.getStatus(), removed.getContentAsString()); + assertEquals(0, ((List) Json.parse(removed.getContentAsString())).size()); + // Or signed by a device of the account, with no password. + byte[] second = TestKeys.random().getBytes("UTF-8"); + passkeys.save(CredentialRecord.builder().credentialId(second) + .userEntityUserId(handle).algorithm(-7).publicKey(new byte[] {1}) + .label("laptop").created(1).lastUsed(2).build()); + MockResponse signed = c.signedAs("POST", "/api/account/passkeys/" + + Base64Url.encode(second) + "/remove", "{}"); + assertEquals(200, signed.getStatus(), signed.getContentAsString()); + assertTrue(passkeys.findByCredentialId(second) == null); + assertTrue(passkeys.findByCredentialId(credentialId) == null); + assertTrue(audit.verify().isEmpty()); + } + + @Test + void listsTheLedgersAssets() throws Exception { + Customer c = new Customer(false); + MockResponse answer = c.send(get("/api/assets")); + assertEquals(200, answer.getStatus(), answer.getContentAsString()); + int seen = 0; + for (Object o : (List) Json.parse(answer.getContentAsString())) { + Map asset = (Map) o; + if ("USD".equals(asset.get("code"))) { + assertEquals(2, ((Number) asset.get("decimals")).intValue()); + assertEquals("FIAT", asset.get("kind")); + seen++; + } else if ("ETH".equals(asset.get("code"))) { + assertEquals(9, ((Number) asset.get("decimals")).intValue()); + assertEquals("CRYPTO", asset.get("kind")); + seen++; + } + } + assertEquals(2, seen); + } + + @Test + void movesMoneyBetweenOwnAccounts() throws Exception { + Customer c = new Customer(false); + c.deposit(c.checking, 50000); + String savings = c.open("USD", "SAVINGS"); + MockResponse answer = c.send(json("/api/transfers", internal(c.checking, savings, 12000)) + .header("Idempotency-Key", key())); + assertEquals(200, answer.getStatus(), answer.getContentAsString()); + assertEquals(38000, c.balance(c.checking)); + assertEquals(12000, c.balance(savings)); + MockResponse history = c.send(get("/api/accounts/" + savings + "/postings")); + assertEquals(1, ((List) Json.parse(history.getContentAsString())).size()); + assertTrue(audit.verify().isEmpty()); + } + + @Test + void aMoveBetweenTwoCurrenciesIsRefusedNotAFault() throws Exception { + Customer c = new Customer(false); + c.deposit(c.checking, 50000); + String euros = c.open("EUR", "CHECKING"); + MockResponse answer = c.send(json("/api/transfers", internal(c.checking, euros, 2500)) + .header("Idempotency-Key", key())); + assertEquals(400, answer.getStatus(), answer.getContentAsString()); + assertEquals(50000, c.balance(c.checking)); + assertEquals(0, c.balance(euros)); + } + + @Test + void refusesWhatTheAccountDoesNotHold() throws Exception { + Customer c = new Customer(false); + String savings = c.open("USD", "SAVINGS"); + MockResponse answer = c.send(json("/api/transfers", internal(c.checking, savings, 5000)) + .header("Idempotency-Key", key())); + assertEquals(409, answer.getStatus()); + assertEquals(0, c.balance(c.checking)); + } + + @Test + void aRetryMovesMoneyOnce() throws Exception { + Customer c = new Customer(false); + c.deposit(c.checking, 50000); + String savings = c.open("USD", "SAVINGS"); + String k = key(); + MockResponse first = c.send(json("/api/transfers", internal(c.checking, savings, 7000)) + .header("Idempotency-Key", k)); + MockResponse again = c.send(json("/api/transfers", internal(c.checking, savings, 7000)) + .header("Idempotency-Key", k)); + assertEquals(200, again.getStatus(), again.getContentAsString()); + assertEquals(Json.parseObject(first.getContentAsString()).get("entryId"), + Json.parseObject(again.getContentAsString()).get("entryId")); + assertEquals(7000, c.balance(savings)); + MockResponse other = c.send(json("/api/transfers", internal(c.checking, savings, 9000)) + .header("Idempotency-Key", k)); + assertEquals(422, other.getStatus()); + MockResponse none = c.send(json("/api/transfers", internal(c.checking, savings, 9000))); + assertEquals(400, none.getStatus()); + } + + @Test + void aStepUpNeedsASignature() throws Exception { + Customer c = new Customer(false); + c.deposit(c.checking, 500000); + String savings = c.open("USD", "SAVINGS"); + String body = internal(c.checking, savings, 200000); + assertEquals(403, c.send(json("/api/transfers", body).header("Idempotency-Key", key())) + .getStatus()); + assertEquals(200, c.signed("/api/transfers", body, key()).getStatus()); + } + + @Test + void aReplayedSignatureIsRefused() throws Exception { + Customer a = new Customer(false); + Customer b = new Customer(false); + a.deposit(a.checking, 50000); + String payee = a.addPayee(b); + String body = a.payBody(payee, 3000); + long ts = System.currentTimeMillis(); + String n = nonce(); + String k = key(); + MockResponse first = a.signed("/api/transfers", body, a.key, ts, n, k); + assertEquals(200, first.getStatus(), first.getContentAsString()); + assertEquals(403, a.signed("/api/transfers", body, a.key, ts, n, k).getStatus()); + assertEquals(403, a.signed("/api/transfers", body, a.key, ts, n, key()).getStatus()); + assertEquals(3000, b.balance(b.checking)); + } + + @Test + void aBadSignatureIsRefused() throws Exception { + Customer a = new Customer(false); + Customer b = new Customer(false); + a.deposit(a.checking, 50000); + String payee = a.addPayee(b); + // Another key, under this device's id. + MockResponse forged = a.signed("/api/transfers", a.payBody(payee, 1000), newKey(), + System.currentTimeMillis(), nonce(), key()); + assertEquals(403, forged.getStatus()); + // A signature of one body sent with another. + String swapKey = key(); + String sig = a.signature("POST", "/api/transfers", a.payBody(payee, 10), + a.key, System.currentTimeMillis(), nonce(), swapKey); + MockResponse swapped = a.send(json("/api/transfers", a.payBody(payee, 9000)) + .header("Bastion-Signature", sig).header("Idempotency-Key", swapKey)); + assertEquals(403, swapped.getStatus()); + // The same signed request under a fresh Idempotency-Key: the key is + // signed, so a captured request cannot be replayed as a new payment. + String rekeyed = a.signature("POST", "/api/transfers", a.payBody(payee, 10), + a.key, System.currentTimeMillis(), nonce(), key()); + MockResponse fresh = a.send(json("/api/transfers", a.payBody(payee, 10)) + .header("Bastion-Signature", rekeyed).header("Idempotency-Key", key())); + assertEquals(403, fresh.getStatus()); + // No signature at all, paying someone else. + MockResponse unsigned = a.send(json("/api/transfers", a.payBody(payee, 10)) + .header("Idempotency-Key", key())); + assertEquals(403, unsigned.getStatus()); + assertEquals(0, b.balance(b.checking)); + } + + @Test + void aSkewedClockIsRefused() throws Exception { + Customer a = new Customer(false); + Customer b = new Customer(false); + a.deposit(a.checking, 50000); + String payee = a.addPayee(b); + long old = System.currentTimeMillis() - 5 * 60 * 1000L; + assertEquals(403, a.signed("/api/transfers", a.payBody(payee, 1000), a.key, old, nonce(), + key()).getStatus()); + long ahead = System.currentTimeMillis() + 5 * 60 * 1000L; + assertEquals(403, a.signed("/api/transfers", a.payBody(payee, 1000), a.key, ahead, + nonce(), key()).getStatus()); + } + + @Test + void removingAndAddingAPayeeAgainKeepsItsCoolingOff() throws Exception { + Customer a = new Customer(false); + Customer b = new Customer(false); + a.deposit(a.checking, 100000); + String payee = a.addPayee(b); + assertEquals(200, a.signed("/api/transfers", a.payBody(payee, 10000), key()).getStatus()); + assertEquals(200, a.signedAs("DELETE", "/api/payees/" + payee, "").getStatus()); + // Removed: off the list, and not payable. + assertTrue(a.send(get("/api/payees")).getContentAsString().indexOf(payee) < 0); + assertEquals(404, a.signed("/api/transfers", a.payBody(payee, 1), key()).getStatus()); + // Added again: the same payee, still cooling off from when it was + // first added, its allowance already spent. + assertEquals(payee, a.addPayee(b)); + MockResponse again = a.signed("/api/transfers", a.payBody(payee, 1), key()); + assertEquals(409, again.getStatus()); + assertTrue(again.getContentAsString().indexOf("new-payee-receive-until-their") >= 0, + again.getContentAsString()); + assertEquals(10000, b.balance(b.checking)); + } + + @Test + void newPayeesAreCappedPerDayRemovedOnesCounted() throws Exception { + Customer a = new Customer(false); + for (int i = 0; i < 5; i++) { + Customer other = new Customer(false); + String id = a.addPayee(other); + assertEquals(200, a.signedAs("DELETE", "/api/payees/" + id, "").getStatus()); + } + Customer sixth = new Customer(false); + MockResponse refused = a.signed("/api/payees", "{\"email\":\"" + sixth.name + + "\",\"nickname\":\"x\"}", null); + assertEquals(429, refused.getStatus(), refused.getContentAsString()); + } + + @Test + void staffNeedASecondFactor() throws Exception { + String coded = SignIn.token(mvc, + com.codenameone.examples.bastion.account.DemoAccounts.AUDITOR, + com.codenameone.examples.bastion.account.DemoAccounts.PASSWORD); + assertEquals(200, mvc.perform(get("/api/books/trial-balance").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + coded)).andReturn().getResponse() + .getStatus()); + String plain = aged(coded, 0, java.util.Arrays.asList("pwd")); + assertEquals(403, mvc.perform(get("/api/books/trial-balance").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + plain)).andReturn().getResponse() + .getStatus()); + } + + @Test + void productionRefusesTheShippedSettings() { + com.codenameone.examples.bastion.ProductionSettings.check(true, "", "", "localhost", + "http://localhost:8080"); + String[][] cases = { + {"", "k", "bank.example", "https://bank.example"}, + {"/keys.json", "", "bank.example", "https://bank.example"}, + {"/keys.json", "k", "localhost", "https://bank.example"}, + {"/keys.json", "k", "bank.example", "http://localhost:8080"}, + }; + for (String[] c : cases) { + boolean refused = false; + try { + com.codenameone.examples.bastion.ProductionSettings.check(false, c[0], c[1], c[2], + c[3]); + } catch (IllegalStateException expected) { + refused = true; + } + assertTrue(refused, java.util.Arrays.toString(c)); + } + com.codenameone.examples.bastion.ProductionSettings.check(false, "/keys.json", "k", + "bank.example", "https://bank.example"); + } + + @Test + void aNewPayeeIsCoolingOff() throws Exception { + Customer a = new Customer(false); + Customer b = new Customer(false); + a.deposit(a.checking, 100000); + String payee = a.addPayee(b); + assertEquals(200, a.signed("/api/transfers", a.payBody(payee, 6000), key()).getStatus()); + assertEquals(200, a.signed("/api/transfers", a.payBody(payee, 4000), key()).getStatus()); + assertEquals(409, a.signed("/api/transfers", a.payBody(payee, 1), key()).getStatus()); + assertEquals(10000, b.balance(b.checking)); + } + + @Test + void theDailyLimitHolds() throws Exception { + Customer a = new Customer(false); + a.deposit(a.checking, 2000000); + String path = "/api/accounts/" + a.checking + "/withdraw"; + assertEquals(409, a.signed(path, "{\"amount\":500001}", key()).getStatus()); + assertEquals(200, a.signed(path, "{\"amount\":500000}", key()).getStatus()); + assertEquals(200, a.signed(path, "{\"amount\":500000}", key()).getStatus()); + assertEquals(409, a.signed(path, "{\"amount\":1}", key()).getStatus()); + assertEquals(1000000, a.balance(a.checking)); + } + + @Test + void aRootedDeviceKeepsMoneyAtHome() throws Exception { + Customer a = new Customer(false); + Customer b = new Customer(false); + a.deposit(a.checking, 50000); + String payee = a.addPayee(b); + TestKeys rooted = newKey(); + a.device = a.register(rooted, true); + a.key = rooted; + assertEquals(403, a.signed("/api/transfers", a.payBody(payee, 1000), key()).getStatus()); + assertEquals(403, a.signed("/api/accounts/" + a.checking + "/withdraw", + "{\"amount\":10}", key()).getStatus()); + String savings = a.open("USD", "SAVINGS"); + assertEquals(200, a.signed("/api/transfers", internal(a.checking, savings, 1000), key()) + .getStatus()); + assertEquals(0, b.balance(b.checking)); + } + + @Test + void anotherCustomersAccountDoesNotExist() throws Exception { + Customer a = new Customer(false); + Customer b = new Customer(false); + a.deposit(a.checking, 50000); + assertEquals(404, b.send(get("/api/accounts/" + a.checking + "/postings")).getStatus()); + assertEquals(404, b.send(get("/api/accounts/nope/postings")).getStatus()); + MockResponse take = b.send(json("/api/transfers", internal(a.checking, b.checking, 100)) + .header("Idempotency-Key", key())); + assertEquals(404, take.getStatus()); + assertEquals(50000, a.balance(a.checking)); + } + + @Test + void aFrozenCardIsDeclined() throws Exception { + Customer a = new Customer(false); + a.deposit(a.checking, 50000); + MockResponse issued = a.signedAs("POST", "/api/accounts/" + a.checking + "/card", ""); + assertEquals(200, issued.getStatus(), issued.getContentAsString()); + Map card = Json.parseObject(issued.getContentAsString()); + assertEquals(4, ((String) card.get("last4")).length()); + String spend = "/api/cards/" + card.get("id") + "/spend"; + String coffee = "{\"amount\":450,\"merchant\":\"Coffee\"}"; + assertEquals(200, a.send(json(spend, coffee).header("Idempotency-Key", key())).getStatus()); + assertEquals(200, a.send(post("/api/cards/" + card.get("id") + "/freeze")).getStatus()); + assertEquals(409, a.send(json(spend, coffee).header("Idempotency-Key", key())).getStatus()); + // Unfreezing puts spending back: a token alone cannot. + String unfreeze = "/api/cards/" + card.get("id") + "/unfreeze"; + assertEquals(403, a.send(post(unfreeze)).getStatus()); + assertEquals(200, a.signedAs("POST", unfreeze, "").getStatus()); + assertEquals(200, a.send(json(spend, coffee).header("Idempotency-Key", key())).getStatus()); + assertEquals(49100, a.balance(a.checking)); + } + + /// The token's sign-in, `minutes` older: the same claims, re-signed with + /// the server's key, as a token kept alive by refreshing would carry. + private String aged(String token, long minutes) throws Exception { + return aged(token, minutes, null); + } + + /// As [#aged(String, long)], with the `amr` claim replaced when `amr` is + /// not null. + private String aged(String token, long minutes, List amr) throws Exception { + String payload = token.substring(token.indexOf('.') + 1, token.lastIndexOf('.')); + Map claims = Json.parseObject(new String(Base64Url.decode(payload), "UTF-8")); + JwtClaimsSet.Builder b = JwtClaimsSet.builder(); + for (Object e : claims.entrySet()) { + Map.Entry entry = (Map.Entry) e; + b.claim((String) entry.getKey(), entry.getValue()); + } + long authTime = ((Number) claims.get("auth_time")).longValue(); + b.claim("auth_time", Long.valueOf(authTime - minutes * 60L)); + if (amr != null) { + b.claim("amr", amr); + } + return new DefaultJwtEncoder(signingKeys).encode(JwtEncoderParameters.from(b.build())) + .getTokenValue(); + } + + @Test + void addingADeviceNeedsAFreshSecondFactorSignIn() throws Exception { + Customer c = new Customer(false); + String key = Base64Url.encode(newKey().publicKey); + String noPassword = "{\"publicKey\":\"" + key + "\",\"platform\":\"and\"," + + "\"rooted\":false,\"attested\":true,\"integrityToken\":\"t\"}"; + String right = noPassword.replace("}", ",\"password\":\"" + PASSWORD + "\"}"); + // A fresh password-only sign-in is refused even with the password: + // a phished session comes with it. The refusal says what to do. + MockResponse plain = c.send(json("/api/devices", right)); + assertEquals(403, plain.getStatus()); + assertTrue(plain.getContentAsString().indexOf("second-factor-add-device") >= 0, + plain.getContentAsString()); + // A token whose sign-in is half an hour old is refused even with a + // code: it is what a stolen, refreshed token looks like. + String fresh = c.token; + c.token = aged(fresh, 30, java.util.Arrays.asList("pwd", "otp", "mfa")); + assertEquals(403, c.send(json("/api/devices", noPassword)).getStatus()); + c.token = aged(fresh, 1, java.util.Arrays.asList("pwd", "otp", "mfa")); + assertEquals(200, c.send(json("/api/devices", noPassword)).getStatus()); + assertTrue(audit.verify().isEmpty()); + } + + @Test + void aPasskeySignInAddsADevice() throws Exception { + Customer c = new Customer(false); + // The token says how its sign-in was made: here, a password alone. + String payload = c.token.substring(c.token.indexOf('.') + 1, c.token.lastIndexOf('.')); + Map claims = Json.parseObject(new String(Base64Url.decode(payload), "UTF-8")); + assertEquals(java.util.Arrays.asList("pwd"), claims.get("amr")); + String noPassword = "{\"publicKey\":\"" + Base64Url.encode(newKey().publicKey) + + "\",\"platform\":\"and\",\"rooted\":false,\"attested\":true," + + "\"integrityToken\":\"t\"}"; + assertEquals(403, c.send(json("/api/devices", noPassword)).getStatus()); + // A passkey the authenticator did not verify the user for (`hwk` with + // no `user`) proves only that the key was present: still the password. + c.token = aged(c.token, 1, java.util.Arrays.asList("hwk")); + assertEquals(403, c.send(json("/api/devices", noPassword)).getStatus()); + assertEquals(200, c.send(json("/api/devices", noPassword.replace("}", + ",\"password\":\"" + PASSWORD + "\"}"))).getStatus()); + // A fresh, user-verified passkey sign-in needs none. + c.token = aged(c.token, 1, java.util.Arrays.asList("hwk", "user", "mfa")); + assertEquals(200, c.send(json("/api/devices", noPassword)).getStatus()); + } + + @Test + void coinsLeaveOnlySignedToAnAllowListedAddress() throws Exception { + Customer c = new Customer(false); + String wallet = c.open("BTC", "WALLET"); + // 0.5 BTC: 30,000.00 at the test index of 60,000.00. + c.deposit(wallet, 50000000L); + String path = "/api/accounts/" + wallet + "/crypto-withdraw"; + String nowhere = "{\"addressId\":\"nope\",\"amount\":100000}"; + assertEquals(403, c.send(json(path, nowhere).header("Idempotency-Key", key())) + .getStatus()); + assertEquals(403, c.signed(path, nowhere, key()).getStatus()); + // Adding an address is signed too. + String add = "{\"asset\":\"BTC\",\"address\":\"bc1qexampleexampleexample0000\"," + + "\"label\":\"cold\"}"; + assertEquals(403, c.send(json("/api/crypto/addresses", add)).getStatus()); + MockResponse added = c.signed("/api/crypto/addresses", add, null); + assertEquals(200, added.getStatus(), added.getContentAsString()); + String id = (String) Json.parseObject(added.getContentAsString()).get("id"); + // So is removing one: the list is what a withdrawal is checked against. + MockResponse spare = c.signed("/api/crypto/addresses", "{\"asset\":\"BTC\",\"address\":" + + "\"bc1qspareexamplespareexample00\",\"label\":\"spare\"}", null); + String spareId = (String) Json.parseObject(spare.getContentAsString()).get("id"); + assertEquals(403, c.unsigned("DELETE", "/api/crypto/addresses/" + spareId, "") + .getStatus()); + assertEquals(200, c.signedAs("DELETE", "/api/crypto/addresses/" + spareId, "") + .getStatus()); + String body = "{\"addressId\":\"" + id + "\",\"amount\":100000}"; + assertEquals(409, c.signed(path, body, key()).getStatus(), "still cooling off"); + // A day later, as far as the address is concerned. + db.execute("UPDATE crypto_address SET usable_at = ? WHERE id = ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 1000L), id}); + assertEquals(403, c.send(json(path, body).header("Idempotency-Key", key())).getStatus()); + MockResponse sent = c.signed(path, body, key()); + assertEquals(200, sent.getStatus(), sent.getContentAsString()); + assertEquals(50000000L - 100000L, c.balance(wallet)); + // 0.2 BTC is 12,000.00: over the 10,000.00 a crypto movement may move. + assertEquals(409, c.signed(path, "{\"addressId\":\"" + id + "\",\"amount\":20000000}", + key()).getStatus()); + // 0.16 BTC is 9,600.00, within a movement; the third in a day passes + // the day's 20,000.00. + String big = "{\"addressId\":\"" + id + "\",\"amount\":16000000}"; + assertEquals(200, c.signed(path, big, key()).getStatus()); + assertEquals(200, c.signed(path, big, key()).getStatus()); + assertEquals(409, c.signed(path, big, key()).getStatus()); + assertEquals(50000000L - 32100000L, c.balance(wallet)); + assertTrue(audit.verify().isEmpty()); + } + + private static String totp(String secret, long offsetMillis) { + return com.codename1.security.Otp.totp(com.codename1.security.Base32.decode(secret), + System.currentTimeMillis() + offsetMillis, 30, 6, + com.codename1.security.Hash.SHA1); + } + + private static boolean refusedAs(MockResponse answer, String code) throws Exception { + return answer.getContentAsString().indexOf("(" + code) >= 0; + } + + @Test + void clientIdempotencyKeysAreTheOwnersAndNeverSystemKeys() throws Exception { + Customer a = new Customer(false); + Customer b = new Customer(false); + a.deposit(a.checking, 50000); + b.deposit(b.checking, 50000); + String aSavings = a.open("USD", "SAVINGS"); + String bSavings = b.open("USD", "SAVINGS"); + // One key, two customers: two payments, not a 422 for the second. + String k = key(); + assertEquals(200, a.send(json("/api/transfers", internal(a.checking, aSavings, 100)) + .header("Idempotency-Key", k)).getStatus()); + MockResponse theirs = b.send(json("/api/transfers", internal(b.checking, bSavings, 100)) + .header("Idempotency-Key", k)); + assertEquals(200, theirs.getStatus(), theirs.getContentAsString()); + assertEquals(100, b.balance(bSavings)); + // A key shaped like the exchange's own is never accepted from a client. + MockResponse hijack = a.send(json("/api/transfers", internal(a.checking, aSavings, 100)) + .header("Idempotency-Key", "hold:" + key())); + assertEquals(400, hijack.getStatus(), hijack.getContentAsString()); + assertEquals(100, a.balance(aSavings)); + } + + @Test + void anAppEnrolledWithAPasswordTokenDoesNotStandInForThePassword() throws Exception { + Customer c = new Customer(false); + // A token whose sign-in is half an hour old cannot enrol an app. + String fresh = c.token; + c.token = aged(fresh, 30); + String withPassword = "{\"password\":\"" + PASSWORD + "\"}"; + assertEquals(403, c.send(json("/api/account/mfa/totp", withPassword)).getStatus()); + c.token = fresh; + // Fresh but password-only: the password has to come with it. + assertEquals(403, c.send(json("/api/account/mfa/totp", "{}")).getStatus()); + MockResponse started = c.send(json("/api/account/mfa/totp", withPassword)); + assertEquals(200, started.getStatus(), started.getContentAsString()); + String secret = (String) Json.parseObject(started.getContentAsString()).get("secret"); + MockResponse confirmed = c.send(json("/api/account/mfa/totp/confirm", + "{\"code\":\"" + totp(secret, 0) + "\",\"password\":\"" + PASSWORD + "\"}")); + assertEquals(200, confirmed.getStatus(), confirmed.getContentAsString()); + // The token still says "pwd": the app does not make it a second factor. + String noPassword = "{\"publicKey\":\"" + Base64Url.encode(newKey().publicKey) + + "\",\"platform\":\"and\",\"rooted\":false,\"attested\":true," + + "\"integrityToken\":\"t\"}"; + assertEquals(403, c.send(json("/api/devices", noPassword)).getStatus()); + // A sign-in made with the code does. + c.token = aged(fresh, 1, java.util.Arrays.asList("pwd", "otp", "mfa")); + assertEquals(200, c.send(json("/api/devices", noPassword)).getStatus()); + } + + @Test + void anOrderIsSignedAndHeldToTheLimits() throws Exception { + Customer a = new Customer(false); + Customer b = new Customer(false); + a.deposit(a.checking, 2000000); + String bWallet = b.open("BTC", "WALLET"); + b.deposit(bWallet, 16000000L); + String path = "/api/exchange/orders"; + // 0.01 BTC at 55,000.00 rests, holding 550.00 and its fee. + String resting = "{\"market\":\"BTC-USD\",\"side\":\"BUY\",\"type\":\"LIMIT\"," + + "\"price\":5500000,\"quantity\":1000000,\"budget\":0}"; + assertEquals(403, a.send(json(path, resting).header("Idempotency-Key", key())) + .getStatus(), "a token alone places nothing"); + MockResponse placed = a.signed(path, resting, key()); + assertEquals(200, placed.getStatus(), placed.getContentAsString()); + // 0.1 BTC is 5,900.00: more than one payment may move. + String big = "{\"market\":\"BTC-USD\",\"side\":\"BUY\",\"type\":\"LIMIT\"," + + "\"price\":5900000,\"quantity\":10000000,\"budget\":0}"; + assertTrue(refusedAs(a.signed(path, big, key()), "more-than-payment-may-move")); + // b sells 0.08 BTC, a buys it: about 4,729.44 of a's day. + String ask = "{\"market\":\"BTC-USD\",\"side\":\"SELL\",\"type\":\"LIMIT\"," + + "\"price\":5900000,\"quantity\":8000000,\"budget\":0}"; + assertEquals(200, b.signed(path, ask, key()).getStatus()); + String take = ask.replace("SELL", "BUY"); + MockResponse filled = a.signed(path, take, key()); + assertEquals(200, filled.getStatus(), filled.getContentAsString()); + assertTrue(filled.getContentAsString().indexOf("FILLED") >= 0, filled.getContentAsString()); + // The fill, the resting order and another 0.08 pass 10,000.00. + assertTrue(refusedAs(a.signed(path, take, key()), "would-pass-limit-day")); + // A payment counts the day's fills too: 5,000.00 more fits, 300.00 + // after it does not, though alone the two would. + String withdraw = "/api/accounts/" + a.checking + "/withdraw"; + assertEquals(200, a.signed(withdraw, "{\"amount\":500000}", key()).getStatus()); + assertTrue(refusedAs(a.signed(withdraw, "{\"amount\":30000}", key()), + "would-pass-limit-day")); + assertTrue(audit.verify().isEmpty()); + } + + @Test + void aStrongSignInMustStillBeFreshToEnrolAnApp() throws Exception { + Customer c = new Customer(false); + String fresh = c.token; + // A passkey sign-in skips the password, never the freshness: hours + // old, it is a session somebody may have walked away from. + c.token = aged(fresh, 30, java.util.Arrays.asList("hwk", "user", "mfa")); + assertEquals(403, c.send(json("/api/account/mfa/totp", "{}")).getStatus()); + c.token = aged(fresh, 1, java.util.Arrays.asList("hwk", "user", "mfa")); + MockResponse started = c.send(json("/api/account/mfa/totp", "{}")); + assertEquals(200, started.getStatus(), started.getContentAsString()); + } + + @Test + void theSimulatedVerifierRunsOnlyOnADevelopmentProfile() { + assertTrue(com.codenameone.examples.bastion.money.SimulatedIntegrityVerifier + .forProfile(true) != null); + boolean refused = false; + try { + com.codenameone.examples.bastion.money.SimulatedIntegrityVerifier.forProfile(false); + } catch (IllegalStateException expected) { + refused = true; + } + assertTrue(refused, "production must declare a real verifier"); + } + + @Test + void wrongPasswordsAtOnceAreAllCounted() throws Exception { + final String user = SignIn.register(mvc, "m" + TestKeys.random().substring(0, 12) + + "@bastion.example", PASSWORD); + Runnable[] calls = new Runnable[5]; + for (int iter = 0; iter < calls.length; iter++) { + calls[iter] = new Runnable() { + @Override + public void run() { + try { + guard.failed(user); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }; + } + Race.together(calls); + assertTrue(guard.locked(user), "five failures at once lock the account"); + } + + @Test + void anOrderHoldCannotBeMovedByTheCustomer() throws Exception { + Customer c = new Customer(false); + c.deposit(c.checking, 50000); + String hold = ledger.open(c.name, "USD", "HOLD", "hold").getId(); + assertEquals(404, c.send(json("/api/transfers", internal(hold, c.checking, 1)) + .header("Idempotency-Key", key())).getStatus()); + assertEquals(404, c.send(json("/api/transfers", internal(c.checking, hold, 1)) + .header("Idempotency-Key", key())).getStatus()); + assertEquals(404, c.send(json("/api/accounts/" + hold + "/deposit", + "{\"amount\":100,\"memo\":\"x\"}").header("Idempotency-Key", key())).getStatus()); + assertEquals(404, c.signed("/api/accounts/" + hold + "/withdraw", "{\"amount\":1}", + key()).getStatus()); + } + + @Test + void aNewPayeesLimitIsValuedInUsdAcrossCurrencies() throws Exception { + Customer a = new Customer(false); + Customer b = new Customer(false); + a.deposit(a.checking, 100000); + String euros = a.open("EUR", "CHECKING"); + a.deposit(euros, 100000); + b.open("EUR", "CHECKING"); + String payee = a.addPayee(b); + assertEquals(200, a.signed("/api/transfers", a.payBody(payee, 5000), key()).getStatus()); + // 46.00 EUR is 50.60 USD at the test rate: with the 50.00 USD paid, + // over the 100.00 USD a new payee may receive. Summed raw it is 96.00. + String inEuros = "{\"fromAccountId\":\"" + euros + "\",\"payeeId\":\"" + payee + + "\",\"amount\":4600,\"memo\":\"rent\"}"; + MockResponse refused = a.signed("/api/transfers", inEuros, key()); + assertTrue(refusedAs(refused, "new-payee-receive-until-their"), refused.getContentAsString()); + // 95.00 EUR alone is 104.50 USD: over it too. + Customer c = new Customer(false); + String cEuros = c.open("EUR", "CHECKING"); + c.deposit(cEuros, 100000); + String cPayee = c.addPayee(b); + MockResponse alone = c.signed("/api/transfers", "{\"fromAccountId\":\"" + cEuros + + "\",\"payeeId\":\"" + cPayee + "\",\"amount\":9500,\"memo\":\"rent\"}", key()); + assertTrue(refusedAs(alone, "new-payee-receive-until-their"), alone.getContentAsString()); + } + + @Test + void theStepUpThresholdIsValuedInUsd() throws Exception { + Customer c = new Customer(false); + String euros = c.open("EUR", "CHECKING"); + c.deposit(euros, 500000); + String savings = c.open("EUR", "SAVINGS"); + // 950.00 EUR is 1,045.00 USD: over the 1,000.00 USD threshold. + String body = internal(euros, savings, 95000); + assertEquals(403, c.send(json("/api/transfers", body).header("Idempotency-Key", key())) + .getStatus()); + assertEquals(200, c.signed("/api/transfers", body, key()).getStatus()); + } + + @Test + void sensitiveCardPayeeAndDeviceChangesAreSigned() throws Exception { + Customer a = new Customer(false); + Customer b = new Customer(false); + // Issuing a card is a new way for money to leave: signed. + assertEquals(403, a.send(post("/api/accounts/" + a.checking + "/card")).getStatus()); + MockResponse issued = a.signedAs("POST", "/api/accounts/" + a.checking + "/card", ""); + assertEquals(200, issued.getStatus(), issued.getContentAsString()); + String limit = "/api/cards/" + Json.parseObject(issued.getContentAsString()).get("id") + + "/limit"; + // Lowering is harmless and token-only; raising is more spending. + assertEquals(200, a.unsigned("PUT", limit, "{\"dailyLimit\":100}").getStatus()); + assertEquals(403, a.unsigned("PUT", limit, "{\"dailyLimit\":5000}").getStatus()); + assertEquals(200, a.signedAs("PUT", limit, "{\"dailyLimit\":5000}").getStatus()); + // Setting it to what it already is is no raise; one cent more is. + assertEquals(200, a.unsigned("PUT", limit, "{\"dailyLimit\":5000}").getStatus()); + assertEquals(403, a.unsigned("PUT", limit, "{\"dailyLimit\":5001}").getStatus()); + + String payee = "/api/payees/" + a.addPayee(b); + String rename = "{\"email\":\"" + b.name + "\",\"nickname\":\"Landlord\"}"; + assertEquals(403, a.unsigned("PUT", payee, rename).getStatus()); + assertEquals(200, a.signedAs("PUT", payee, rename).getStatus()); + assertEquals(403, a.unsigned("DELETE", payee, "").getStatus()); + assertEquals(200, a.signedAs("DELETE", payee, "").getStatus()); + + // Revoking: another device's signature, or the password again. + String second = a.register(newKey(), false); + String revoke = "/api/devices/" + second + "/revoke"; + assertEquals(403, a.unsigned("POST", revoke, "{}").getStatus()); + assertEquals(403, a.unsigned("POST", revoke, "{\"password\":\"not it\"}").getStatus()); + assertEquals(200, a.unsigned("POST", revoke, "{\"password\":\"" + PASSWORD + "\"}") + .getStatus()); + String third = a.register(newKey(), false); + assertEquals(200, a.signedAs("POST", "/api/devices/" + third + "/revoke", "{}") + .getStatus()); + } + + @Test + void theDailyLimitCountsEveryCurrency() throws Exception { + Customer a = new Customer(false); + a.deposit(a.checking, 2000000); + String euros = a.open("EUR", "CHECKING"); + a.deposit(euros, 2000000); + // 4,600.00 EUR is 5,060.00 USD at the test rate: over one payment. + assertEquals(409, a.signed("/api/accounts/" + euros + "/withdraw", "{\"amount\":460000}", + key()).getStatus()); + String usd = "/api/accounts/" + a.checking + "/withdraw"; + assertEquals(200, a.signed(usd, "{\"amount\":500000}", key()).getStatus()); + assertEquals(200, a.signed(usd, "{\"amount\":500000}", key()).getStatus()); + // The day's 10,000.00 USD is used up: one euro cent more is refused. + assertEquals(409, a.signed("/api/accounts/" + euros + "/withdraw", "{\"amount\":1}", + key()).getStatus()); + } + + @Test + void accountsOpenedAtOnceStayUnderTheCap() throws Exception { + final Customer c = new Customer(false); + for (int iter = c.accounts().size(); iter < 18; iter++) { + c.open("USD", "SAVINGS"); + } + Runnable[] calls = new Runnable[6]; + for (int iter = 0; iter < calls.length; iter++) { + calls[iter] = new Runnable() { + @Override + public void run() { + try { + c.send(json("/api/accounts", + "{\"asset\":\"USD\",\"kind\":\"SAVINGS\",\"name\":\"r\"}")); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }; + } + Race.together(calls); + assertEquals(20, c.accounts().size()); + } + + @Test + void verifyFindsABalanceItsPostingsDoNotExplain() throws Exception { + Customer c = new Customer(false); + c.deposit(c.checking, 1234); + assertTrue(ledger.verify(java.util.Arrays.asList(c.name)).isEmpty()); + db.execute("UPDATE ledger_account SET balance = balance + 1 WHERE id = ?", + new Object[] {c.checking}); + List problems = ledger.verify(java.util.Arrays.asList(c.name)); + assertEquals(1, problems.size(), String.valueOf(problems)); + db.execute("UPDATE ledger_account SET balance = balance - 1 WHERE id = ?", + new Object[] {c.checking}); + } + + @Test + void theAuditChainIsVerifiedAcrossPages() throws Exception { + for (int iter = 0; iter < 520; iter++) { + audit.append("pager", "PAGE_TEST", "s", "#" + iter); + } + assertTrue(audit.verify().isEmpty()); + List rows = db.query("SELECT MAX(seq) AS s FROM audit_event", new Object[0]); + long last = ((Number) ((Map) rows.get(0)).get("s")).longValue(); + db.execute("UPDATE audit_event SET detail = 'changed' WHERE seq = ?", + new Object[] {Long.valueOf(last - 1)}); + List problems = audit.verify(); + db.execute("UPDATE audit_event SET detail = '#518' WHERE seq = ?", + new Object[] {Long.valueOf(last - 1)}); + assertEquals(1, problems.size(), String.valueOf(problems)); + assertTrue(audit.verify().isEmpty()); + } + + @Test + void isolationIsReadFromTxIsolationWhereTheNewNameIsUnknown() throws Exception { + Object level = com.codenameone.examples.bastion.ledger.IsolationCheck.level(sql -> { + if (sql.indexOf("@@transaction_isolation") >= 0) { + throw new java.io.IOException("Unknown system variable 'transaction_isolation'"); + } + return "READ-COMMITTED"; + }); + assertEquals("READ-COMMITTED", level); + } + + @Test + void aPayeeAnswersTheSameWhetherOrNotAnyoneBanksThere() throws Exception { + Customer a = new Customer(false); + a.deposit(a.checking, 50000); + MockResponse added = a.signed("/api/payees", "{\"email\":\"nobody-" + + TestKeys.random().substring(0, 10) + "@bastion.example\",\"nickname\":\"x\"}", + null); + assertEquals(200, added.getStatus(), added.getContentAsString()); + String ghost = (String) Json.parseObject(added.getContentAsString()).get("id"); + MockResponse paid = a.signed("/api/transfers", a.payBody(ghost, 100), key()); + assertEquals(409, paid.getStatus()); + assertTrue(refusedAs(paid, "payee-cannot-receive"), paid.getContentAsString()); + } + + @Test + void payingAFrozenCustomerDoesNotSayFrozen() throws Exception { + Customer a = new Customer(false); + Customer b = new Customer(false); + a.deposit(a.checking, 50000); + String payee = a.addPayee(b); + ledger.freeze(b.checking, true); + MockResponse paid = a.signed("/api/transfers", a.payBody(payee, 100), key()); + assertEquals(409, paid.getStatus()); + assertTrue(refusedAs(paid, "payee-cannot-receive"), paid.getContentAsString()); + assertEquals(50000, a.balance(a.checking)); + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/Race.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/Race.java new file mode 100644 index 00000000000..55e4718fd43 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/Race.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/// Starts calls at the same moment, as near as threads allow. A count and a +/// flag, polled: the class library a compiled server links against has the +/// atomics and no latch. +final class Race { + private Race() { + } + + static void together(Runnable[] calls) throws Exception { + final AtomicInteger ready = new AtomicInteger(); + final AtomicBoolean go = new AtomicBoolean(); + Thread[] threads = new Thread[calls.length]; + for (int iter = 0; iter < calls.length; iter++) { + final Runnable call = calls[iter]; + threads[iter] = new Thread(new Runnable() { + @Override + public void run() { + ready.incrementAndGet(); + long giveUp = System.currentTimeMillis() + 30000L; + while (!go.get()) { + if (System.currentTimeMillis() > giveUp) { + return; + } + Thread.yield(); + } + call.run(); + } + }, "race-" + iter); + threads[iter].start(); + } + while (ready.get() < calls.length) { + Thread.yield(); + } + go.set(true); + for (Thread t : threads) { + t.join(60000L); + } + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/SignIn.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/SignIn.java new file mode 100644 index 00000000000..37ebae782e7 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/SignIn.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codenameone.examples.bastion.account.DemoAccounts; + +import com.codename1.backend.Json; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codename1.backend.test.MockResponse; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; + +/// Signing in the way the app does: the password form, a code when the +/// account asks for one, then the authorization-code flow with PKCE. +final class SignIn { + private static final String VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + private static final String CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + static final String HOST = "bastion.test:8080"; + + private SignIn() { + } + + static MockRequestBuilder formPost(String path, String... pairs) { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < pairs.length; i += 2) { + if (body.length() > 0) { + body.append('&'); + } + body.append(OAuth2Parameters.encode(pairs[i])).append('=') + .append(OAuth2Parameters.encode(pairs[i + 1])); + } + return post(path).contentType("application/x-www-form-urlencoded").content(body.toString()) + .header("Host", HOST); + } + + static MockResponse password(MockMvc mvc, String username, String password) throws Exception { + return mvc.perform(formPost("/login", "username", username, "password", password)) + .andReturn().getResponse(); + } + + static String session(MockMvc mvc, String username, String password) throws Exception { + MockResponse answer = password(mvc, username, password); + String redirect = answer.getRedirectedUrl(); + if (answer.getStatus() == 302 && redirect != null && redirect.indexOf("/login/mfa") >= 0 + && isDemoStaff(username)) { + // The demo staff have an authenticator app; give its code the way + // the staff member would, a step no earlier sign-in used. + String pending = answer.getCookie("CN1SESSION"); + MockResponse coded = mvc.perform(formPost("/login/mfa", "code", staffCode(username)) + .cookie("CN1SESSION", pending)).andReturn().getResponse(); + if (coded.getStatus() != 200) { + return null; + } + String session = coded.getCookie("CN1SESSION"); + return session != null ? session : pending; + } + return answer.getStatus() == 200 ? answer.getCookie("CN1SESSION") : null; + } + + private static boolean isDemoStaff(String username) { + return DemoAccounts.ADMIN.equals(username) || DemoAccounts.SECOND_ADMIN.equals(username) + || DemoAccounts.AUDITOR.equals(username); + } + + /// The last step each demo staff member's code was used in: a step's code + /// is good once, and the server takes the one before and after too. + private static final java.util.Map USED_STEP = + new java.util.HashMap(); + + private static synchronized String staffCode(String username) throws Exception { + Long last = USED_STEP.get(username); + long step = System.currentTimeMillis() / 30000L - 1; + if (last != null && last.longValue() >= step) { + step = last.longValue() + 1; + } + while (step > System.currentTimeMillis() / 30000L + 1) { + Thread.sleep(1000); + } + USED_STEP.put(username, Long.valueOf(step)); + return com.codename1.security.Otp.totp( + com.codename1.security.Base32.decode(DemoAccounts.STAFF_TOTP_SECRET), + step * 30000L, 30, 6, com.codename1.security.Hash.SHA1); + } + + /// The authorization code a signed-in session gets, or null when the + /// session is not signed in. + static String code(MockMvc mvc, String session) throws Exception { + String location = mvc.perform(get("/oauth2/authorize?response_type=code&client_id=" + + SecurityConfig.CLIENT_ID + "&redirect_uri=" + + OAuth2Parameters.encode(SecurityConfig.LOOPBACK_REDIRECT) + + "&scope=openid%20profile&state=s-1&nonce=n-1&code_challenge=" + CHALLENGE + + "&code_challenge_method=S256") + .header("Host", HOST).cookie("CN1SESSION", session)) + .andReturn().getResponse().getRedirectedUrl(); + if (location == null || !location.startsWith(SecurityConfig.LOOPBACK_REDIRECT) + || location.indexOf("code=") < 0) { + return null; + } + int at = location.indexOf("code=") + 5; + int end = location.indexOf('&', at); + return location.substring(at, end < 0 ? location.length() : end); + } + + static Map exchange(MockMvc mvc, String session) throws Exception { + String code = code(mvc, session); + if (code == null) { + throw new IllegalStateException("no authorization code"); + } + String body = mvc.perform(formPost("/oauth2/token", + "grant_type", "authorization_code", + "code", code, + "redirect_uri", SecurityConfig.LOOPBACK_REDIRECT, + "code_verifier", VERIFIER, + "client_id", SecurityConfig.CLIENT_ID)) + .andReturn().getResponse().getContentAsString(); + return Json.parseObject(body); + } + + static Map tokens(MockMvc mvc, String username, String password) throws Exception { + String session = session(mvc, username, password); + if (session == null) { + throw new IllegalStateException("could not sign in as " + username); + } + return exchange(mvc, session); + } + + static String token(MockMvc mvc, String username, String password) throws Exception { + Object token = tokens(mvc, username, password).get("access_token"); + if (!(token instanceof String)) { + throw new IllegalStateException("no access token for " + username); + } + return (String) token; + } + + /// Opens an account through the API and answers its username. + static String register(MockMvc mvc, String username, String password) throws Exception { + int status = mvc.perform(post("/api/account/register").header("Host", HOST) + .contentType("application/json") + .content("{\"email\":\"" + username + "\",\"password\":\"" + password + + "\",\"displayName\":\"Test Person\"}")) + .andReturn().getResponse().getStatus(); + if (status != 200) { + throw new IllegalStateException("register answered " + status); + } + return username; + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/SigningVectorTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/SigningVectorTest.java new file mode 100644 index 00000000000..8ec1fb4616e --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/SigningVectorTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codenameone.examples.bastion.api.SignedRequest; +import org.junit.jupiter.api.Test; + + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// The canonical text the server verifies, for the vector the app's +/// `RequestSignerTest` signs: the same request must give the same text on +/// both sides, byte for byte. +class SigningVectorTest { + @Test + void canonicalForm() throws Exception { + String body = "{\"amount\":1500,\"memo\":\"rent\"}"; + byte[] digest = com.codename1.backend.Crypto.sha256(body.getBytes("UTF-8")); + assertEquals("POST|/api/accounts/a1/withdraw|" + + "27faf49a231437b9d9308db1799c1ab06d8665c087da17478d59acbefa20a125|" + + "idem-0123456789abcdef|AAECAwQFBgcICQoLDA0ODw|1767225600000", + SignedRequest.canonical("POST", "/api/accounts/a1/withdraw", digest, + "idem-0123456789abcdef", "AAECAwQFBgcICQoLDA0ODw", 1767225600000L)); + assertEquals("dev1", SignedRequest.field( + "v1;device=dev1;ts=1;nonce=n;sig=s", "device")); + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/TestKeys.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/TestKeys.java new file mode 100644 index 00000000000..23a8e26238d --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/TestKeys.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.Base64Url; +import com.codename1.backend.Crypto; +import com.codename1.backend.security.crypto.Der; + +import java.io.IOException; + +/// Device keys for the tests, and signing with them. +/// +/// Fixed P-256 pairs rather than generated ones: the tests also run compiled +/// by ParparVM, whose class library has no `java.security`, and the backend's +/// [Crypto] signs and verifies but does not make EC keys. Handed out in turn, +/// so two calls in a row answer two different keys. Test keys only: they +/// are published here and sign nothing but test requests. +final class TestKeys { + private static final String[][] PAIRS = { + {"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgju8FJ0vDrHAxiMt3KOvLi4KDkfETw83GixJWm0GYXqmhRANCAARdjfihuTDUWlL-iKJnQJyaFA5pHj4u5_MbSjY9AUMMePOgkkWbOyR7fwzXBDZb6R-05yF9LT6_iuJGk9YOrW2P", + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEXY34obkw1FpS_oiiZ0CcmhQOaR4-LufzG0o2PQFDDHjzoJJFmzske38M1wQ2W-kftOchfS0-v4riRpPWDq1tjw"}, + {"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgd3hPSEVByOnMtUgtycFhiUjN5AFfqunFuT0YEW80fFahRANCAASAVNBQBONoXyJIHUZ1x8FyUe5IkEcjDE-Y0RQ93mxFyRbP9dUt9SC3KZf7S8k_NMxc3djq8Am1F-a58i-AUJ31", + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgFTQUATjaF8iSB1GdcfBclHuSJBHIwxPmNEUPd5sRckWz_XVLfUgtymX-0vJPzTMXN3Y6vAJtRfmufIvgFCd9Q"}, + {"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9xBmzTdupOUDuBwEL2KPX18MNAZBTQsrR9g3qaeeKhShRANCAAQ00wLx_sW8pYko5GaHuwE05bLAb-fSr1rH8CuRvU1tZMqZqnJW2IHNNsmuS3twnYiMS_rrXL0_bVXgUlh7h4WC", + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENNMC8f7FvKWJKORmh7sBNOWywG_n0q9ax_Arkb1NbWTKmapyVtiBzTbJrkt7cJ2IjEv661y9P21V4FJYe4eFgg"}, + {"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgEtrnraBY3Cf7pW8j4NjQJfcVM5dMEgPwuiPAtW2iJAWhRANCAATr5WFd6HhZpCeZpqZIhKqGcONPrRVWpmAx8sSYePYc1IAGbqmsiRf77nmiW0l31dCovDiDEWZtjypF4Q0QFTfo", + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6-VhXeh4WaQnmaamSISqhnDjT60VVqZgMfLEmHj2HNSABm6prIkX--55oltJd9XQqLw4gxFmbY8qReENEBU36A"} + }; + private static int next; + + final byte[] privateKey; + final byte[] publicKey; + + private TestKeys(byte[] privateKey, byte[] publicKey) { + this.privateKey = privateKey; + this.publicKey = publicKey; + } + + static synchronized TestKeys next() { + String[] pair = PAIRS[next++ % PAIRS.length]; + return new TestKeys(Base64Url.decode(pair[0]), Base64Url.decode(pair[1])); + } + + /// The public key as the server takes it: X.509 SubjectPublicKeyInfo, base64url. + String publicText() { + return Base64Url.encode(publicKey); + } + + /// ES256 over `text`, as JOSE r||s. + byte[] sign(byte[] text) throws IOException { + return Der.ecdsaDerToJose(Crypto.sign(Crypto.ES256, privateKey, text), 32); + } + + /// A random hex id of 32 characters, where a test wants a fresh key. + static String random() { + try { + return Ids.next(); + } catch (IOException noRandom) { + throw new IllegalStateException(noRandom); + } + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/WebSignInTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/WebSignInTest.java new file mode 100644 index 00000000000..6999c851030 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/WebSignInTest.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockResponse; +import com.codenameone.examples.bastion.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Signing in from the app running in a browser, which sees only how each +/// request ended: no redirect it can stop at, and no cookie it can read. +@BackendTest +class WebSignInTest { + private static final String VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + private static final String CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + private static final String HERE = "http://" + SignIn.HOST + SecurityConfig.WEB_REDIRECT_PATH; + + @Autowired + private MockMvc mvc; + + private String authorize(String session, String redirect) throws Exception { + return mvc.perform(get("/oauth2/authorize?response_type=code&client_id=" + + SecurityConfig.CLIENT_ID + "&redirect_uri=" + OAuth2Parameters.encode(redirect) + + "&scope=openid%20profile&state=s-web&code_challenge=" + CHALLENGE + + "&code_challenge_method=S256") + .header("Host", SignIn.HOST).cookie("CN1SESSION", session)) + .andReturn().getResponse().getRedirectedUrl(); + } + + @Test + void thePasswordIsAnsweredWithAStatus() throws Exception { + MockResponse accepted = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + ) + .andExpect(status().isOk()).andReturn().getResponse(); + // Not a redirect: a browser would follow one and report the page at its end. + assertNull(accepted.getRedirectedUrl()); + assertNotNull(accepted.getCookie("CN1SESSION")); + + MockResponse refused = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", "not-the-password") + ) + .andExpect(status().isUnauthorized()).andReturn().getResponse(); + assertNull(refused.getRedirectedUrl()); + assertNull(refused.getCookie("CN1SESSION")); + } + + @Test + void theCodeComesBackThroughThisServer() throws Exception { + String session = SignIn.session(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + String location = authorize(session, HERE); + assertNotNull(location); + assertTrue(location.startsWith(HERE + "?"), location); + + // What the browser does next, and what the app reads. + String answer = mvc.perform(get(location.substring(("http://" + SignIn.HOST).length())) + .header("Host", SignIn.HOST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("s-web")) + .andReturn().getResponse().getContentAsString(); + String code = (String) Json.parseObject(answer).get("code"); + assertNotNull(code); + + Map tokens = Json.parseObject(mvc.perform(SignIn.formPost("/oauth2/token", + "grant_type", "authorization_code", + "code", code, + "redirect_uri", HERE, + "code_verifier", VERIFIER, + "client_id", SecurityConfig.CLIENT_ID)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + String token = (String) tokens.get("access_token"); + assertNotNull(token); + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } + + @Test + void aCodeIsNeverSentToAnotherServer() throws Exception { + String session = SignIn.session(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + // Same path, somebody else's server: the address has to be this one. + String elsewhere = authorize(session, "http://attacker.example" + SecurityConfig.WEB_REDIRECT_PATH); + assertTrue(elsewhere == null || elsewhere.indexOf("attacker.example") < 0, String.valueOf(elsewhere)); + String otherPath = authorize(session, "http://" + SignIn.HOST + "/somewhere/else"); + assertTrue(otherPath == null || otherPath.indexOf("code=") < 0, String.valueOf(otherPath)); + } + + @Test + void theInstalledAppStillSignsInItsOwnWay() throws Exception { + assertNotNull(SignIn.token(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD)); + } + + @Test + void theCodePageSaysWhenThereIsNoCode() throws Exception { + mvc.perform(get(SecurityConfig.WEB_REDIRECT_PATH + "?error=access_denied&state=s-web") + .header("Host", SignIn.HOST)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("access_denied")); + } + + @Test + void aSignInAnotherSiteSubmittedIsRefused() throws Exception { + MockResponse forged = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + .header("Origin", "http://attacker.example")) + .andExpect(status().isForbidden()).andReturn().getResponse(); + assertNull(forged.getCookie("CN1SESSION")); + // A page in a sandboxed frame, or opened from a file. + mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + .header("Origin", "null")) + .andExpect(status().isForbidden()); + // The same name on another port or scheme is another site. + mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + .header("Origin", "https://" + SignIn.HOST)) + .andExpect(status().isForbidden()); + mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + .header("Origin", "http://bastion.test:9090")) + .andExpect(status().isForbidden()); + // The rest of what changes something on this chain is held to it too. + mvc.perform(SignIn.formPost("/oauth2/token", "grant_type", "authorization_code") + .header("Origin", "http://attacker.example")) + .andExpect(status().isForbidden()); + mvc.perform(SignIn.formPost("/logout") + .header("Origin", "http://attacker.example")) + .andExpect(status().isForbidden()); + } + + @Test + void aSignInFromThisServersOwnPageIsAccepted() throws Exception { + // A browser spells the host in lower case; the comparison does not care. + MockResponse own = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + .header("Origin", "HTTP://BASTION.TEST:8080")) + .andExpect(status().isOk()).andReturn().getResponse(); + assertNotNull(own.getCookie("CN1SESSION")); + // And the installed app, which is not a browser and names no origin. + assertNotNull(SignIn.session(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD)); + } + + @Test + void theOriginIsHeldToTheIssuerWhenOneIsConfigured() { + assertTrue(SecurityConfig.sameOrigin("https://bastion.example", "https://bastion.example")); + assertTrue(SecurityConfig.sameOrigin("https://BASTION.example", "https://bastion.example/auth")); + assertTrue(!SecurityConfig.sameOrigin("https://bastion.example.attacker.example", + "https://bastion.example")); + assertTrue(!SecurityConfig.sameOrigin("http://bastion.example", "https://bastion.example")); + assertTrue(!SecurityConfig.sameOrigin("https://bastion.example:8443", "https://bastion.example")); + // With an issuer the address a request arrived at is never consulted. + assertEquals("https://bastion.example", SecurityConfig.here("https://bastion.example")); + // With none, and no request in hand, there is no address to accept. + assertNull(SecurityConfig.here("")); + } + + @Test + void theSessionCookieIsNotSentFromOtherSitesOrReadByScripts() throws Exception { + String header = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + ) + .andReturn().getResponse().getHeader("Set-Cookie"); + assertNotNull(header); + assertTrue(header.indexOf("HttpOnly") > 0, header); + assertTrue(header.indexOf("SameSite=Lax") > 0 || header.indexOf("SameSite=Strict") > 0, header); + } + + @Test + void theSessionEndsWhenTheAppHasItsTokens() throws Exception { + String session = SignIn.session(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + String location = authorize(session, HERE); + assertTrue(location.indexOf("code=") > 0, location); + String answer = mvc.perform(get(location.substring(("http://" + SignIn.HOST).length())) + .header("Host", SignIn.HOST)) + .andReturn().getResponse().getContentAsString(); + Map tokens = Json.parseObject(mvc.perform(SignIn.formPost("/oauth2/token", + "grant_type", "authorization_code", + "code", (String) Json.parseObject(answer).get("code"), + "redirect_uri", HERE, + "code_verifier", VERIFIER, + "client_id", SecurityConfig.CLIENT_ID)) + .andReturn().getResponse().getContentAsString()); + + // Asking nicely does not end it: only a POST does. + mvc.perform(get("/logout").header("Host", SignIn.HOST).cookie("CN1SESSION", session)); + assertTrue(authorize(session, HERE).indexOf("code=") > 0); + + MockResponse ended = mvc.perform(SignIn.formPost("/logout") + .header("Origin", "http://" + SignIn.HOST) + .cookie("CN1SESSION", session)) + .andExpect(status().isNoContent()).andReturn().getResponse(); + assertNull(ended.getRedirectedUrl()); + // The cookie earns no more codes... + String after = authorize(session, HERE); + assertTrue(after == null || after.indexOf("code=") < 0, String.valueOf(after)); + // ...and the tokens it earned are untouched. + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + tokens.get("access_token"))) + .andExpect(status().isOk()); + mvc.perform(SignIn.formPost("/oauth2/token", + "grant_type", "refresh_token", + "refresh_token", (String) tokens.get("refresh_token"), + "client_id", SecurityConfig.CLIENT_ID)) + .andExpect(status().isOk()); + } + + @Test + void theCodePageIsDataAndNothingElse() throws Exception { + MockResponse page = mvc.perform(get(SecurityConfig.WEB_REDIRECT_PATH + + "?code=%3Cscript%3Ealert(1)%3C/script%3E&state=s").header("Host", SignIn.HOST)) + .andExpect(status().isOk()).andReturn().getResponse(); + assertTrue(String.valueOf(page.getContentType()).startsWith("application/json"), + String.valueOf(page.getContentType())); + assertEquals("nosniff", page.getHeader("X-Content-Type-Options")); + assertEquals("no-store", page.getHeader("Cache-Control")); + } + + @Test + void anIssuerIsTheOnlyWebAddressWhenOneIsConfigured() { + com.codename1.backend.security.oauth2.server.authorization.RegisteredClient app = + com.codename1.backend.security.oauth2.server.authorization.RegisteredClient + .withId("bastion").clientId(SecurityConfig.CLIENT_ID) + .clientAuthenticationMethod(com.codename1.backend.security.oauth2.core + .ClientAuthenticationMethod.NONE) + .authorizationGrantType(com.codename1.backend.security.oauth2.core + .AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(SecurityConfig.LOOPBACK_REDIRECT).build(); + java.util.Set uris = SecurityConfig.withWebRedirect(app, "https://bastion.example") + .getRedirectUris(); + assertTrue(uris.contains("https://bastion.example/signin/code"), uris.toString()); + assertTrue(uris.contains(SecurityConfig.LOOPBACK_REDIRECT), uris.toString()); + assertEquals(2, uris.size(), uris.toString()); + // With none configured and no request in hand, nothing is added. + assertEquals(1, SecurityConfig.withWebRedirect(app, "").getRedirectUris().size()); + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/books/ApprovalsTestHooks.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/books/ApprovalsTestHooks.java new file mode 100644 index 00000000000..496247de1a8 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/books/ApprovalsTestHooks.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.books; + +/// Test-only access to the package-private seams of this package, so the +/// production class exposes none of them publicly. +public final class ApprovalsTestHooks { + private ApprovalsTestHooks() { + } + + /// Runs `pause` between reading a maker's 24h total and acting on it; + /// null removes it. + public static void pauseAfterRecentTotal(Runnable pause) { + Approvals.TEST_ONLY_AFTER_RECENT_TOTAL.set(pause); + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/chain/ChainIntegrationTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/chain/ChainIntegrationTest.java new file mode 100644 index 00000000000..c94ab0a095e --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/chain/ChainIntegrationTest.java @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +import com.codename1.backend.DataSource; +import java.time.Instant; +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codenameone.examples.bastion.account.DemoAccounts; +import com.codenameone.examples.bastion.api.CustodyDto; +import com.codenameone.examples.bastion.domain.ChainTransfer; +import com.codenameone.examples.bastion.domain.CryptoAddress; +import com.codenameone.examples.bastion.domain.LedgerAccount; +import com.codenameone.examples.bastion.ledger.AccountKinds; +import com.codenameone.examples.bastion.ledger.Ledger; +import com.codenameone.examples.bastion.ledger.Leg; +import com.codenameone.examples.bastion.money.Money; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Real Ether on a real node: a development chain (geth --dev), which mines a +/// block a second and starts with one funded, unlocked account. Runs only +/// when `BASTION_CHAIN_RPC_URL` names one; `scripts/bastion/run-chain-test.sh` +/// starts the node and sets everything this needs, and CI runs it in the +/// `chain` job. +/// +/// Everything the bank does here is what it would do on mainnet; only the +/// test's own funding uses the node's unlocked account. +@BackendTest +class ChainIntegrationTest { + private static final String RPC = System.getenv("BASTION_CHAIN_RPC_URL"); + private static final long ONE_ETH = 1000000000L; + + @Autowired + private Chain chain; + @Autowired + private Money money; + @Autowired + private Ledger ledger; + @Autowired + private DataSource db; + + @BeforeAll + static void needsANode() { + Assumptions.assumeTrue(RPC != null && RPC.length() > 0, + "set BASTION_CHAIN_RPC_URL (scripts/bastion/run-chain-test.sh) to run"); + } + + @Test + void etherCrossesTheChainBothWaysAndTheBooksAgree() throws Exception { + assertTrue(chain.enabled()); + String owner = DemoAccounts.CUSTOMER; + String deposit = chain.depositAddress(owner).getAddress(); + assertEquals(deposit, chain.depositAddress(owner).getAddress(), "one address per customer"); + + // A deposit of 1.5 ETH, from outside the bank. + String funding = fund(deposit, "0x14d1120d7b160000"); + until("the deposit is credited", () -> credited(funding)); + LedgerAccount wallet = wallet(owner); + assertEquals(15L * ONE_ETH / 10, wallet.getBalance()); + + // Swept into the hot wallet, minus its own fee. + until("the sweep is final", () -> swept()); + assertTrue(balance(chain.hotWallet()) > ONE_ETH, "the hot wallet holds the deposit"); + + // The sweep's fee left the chain while the customer is still owed the + // whole deposit, so custody is short by that fee: the bank pays network + // fees from a float of its own. Until it funds one nothing goes out. + assertTrue(chain.custody() < chain.custodyReport().owed(), "a sweep fee is the bank's to cover"); + final long before = balance(chain.hotWallet()); + fund(chain.hotWallet(), "0x2386f26fc10000"); // 0.01 ETH + until("the float arrives", () -> balanceQuietly(chain.hotWallet()) > before); + + // A withdrawal of 0.4 ETH to an outside address, past its cooling-off. + String outside = "0x" + Hex32.encode(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, + 13, 14, 15, 16, 17, 18, 19, 20}); + CryptoAddress to = new CryptoAddress(); + to.setId("chaintest-address"); + to.setOwner(owner); + to.setAsset("ETH"); + to.setAddress(outside); + to.setLabel("test"); + to.setUsableAt(Instant.EPOCH); + to.setCreatedAt(Instant.now()); + // In memory only: the endpoint's allow-list and cooling-off checks + // are ExchangeTest's subject, not this one's. + String entry = money.withdrawCrypto(owner, "chain-test-withdrawal-1", wallet, + to, 4L * ONE_ETH / 10).entryId(); + assertEquals(11L * ONE_ETH / 10, wallet(owner).getBalance()); + until("the withdrawal is final", () -> status(entry).equals(Chain.CONFIRMED)); + // Exactly the amount arrived, on the chain, at the address. + assertEquals(4L * ONE_ETH / 10, balance(outside)); + assertEquals(0L, ledger.system("ETH", AccountKinds.SYS_CHAIN_TRANSIT).getBalance()); + ChainTransfer out = chain.transfer(entry); + assertTrue(out.getTxHash().startsWith("0x") && out.getFee() > 0); + + // Reserves: the chain holds at least what the ledger owes. + CustodyDto custody = chain.custodyReport(); + assertTrue(custody.onChain() >= custody.owed(), custody.toString()); + assertTrue(ledger.verify(new ArrayList(Arrays.asList(owner))).isEmpty()); + + // Coins minted in the ledger without a deposit: no withdrawal is + // signed until a person looks. + LedgerAccount cash = ledger.system("ETH", AccountKinds.SYS_CASH); + ledger.post("chain-test-unbacked-mint", "DEPOSIT", owner, "not backed", + Arrays.asList(new Leg(cash.getId(), -100L * ONE_ETH), new Leg(wallet.getId(), 100L * ONE_ETH))); + String held = money.withdrawCrypto(owner, "chain-test-withdrawal-2", wallet(owner), to, + ONE_ETH / 10).entryId(); + chain.tick(); + chain.tick(); + ChainTransfer stuck = chain.transfer(held); + assertEquals(Chain.QUEUED, stuck.getStatus()); + assertTrue(stuck.getDetail().contains("owes"), stuck.getDetail()); + } + + private boolean credited(String txHash) { + chain.tick(); + try { + for (ChainTransfer t : chain.transfers()) { + if (Chain.IN.equals(t.getDirection()) && txHash.equals(t.getTxHash())) { + return true; + } + } + return false; + } catch (Exception e) { + return false; + } + } + + private boolean swept() { + chain.tick(); + try { + for (ChainTransfer t : chain.transfers()) { + if (Chain.SWEEP.equals(t.getDirection()) && Chain.CONFIRMED.equals(t.getStatus())) { + return true; + } + } + } catch (Exception e) { + return false; + } + return false; + } + + private String status(String id) { + chain.tick(); + try { + return chain.transfer(id).getStatus(); + } catch (Exception e) { + return ""; + } + } + + private LedgerAccount wallet(String owner) throws Exception { + for (LedgerAccount a : ledger.accountsOf(owner)) { + if ("ETH".equals(a.getAsset()) && AccountKinds.WALLET.equals(a.getKind())) { + return a; + } + } + throw new AssertionError("no ETH wallet"); + } + + private long balanceQuietly(String address) { + try { + return balance(address); + } catch (Exception e) { + return -1; + } + } + + private void until(String what, BooleanSupplier done) throws Exception { + long end = System.currentTimeMillis() + 120000L; + while (!done.getAsBoolean()) { + if (System.currentTimeMillis() > end) { + // A tick records why it failed in the audit; without it a + // timeout says nothing about the cause. + throw new AssertionError("timed out waiting for " + what + "; chain failures: " + + db.query("SELECT detail FROM audit_event WHERE action LIKE 'CHAIN%'" + + " ORDER BY seq DESC", new Object[0])); + } + Thread.sleep(1000); + } + } + + /// Sends `wei` from the development chain's unlocked account. + @SuppressWarnings("unchecked") + private static String fund(String to, String wei) throws Exception { + List accounts = (List) rpc("eth_accounts"); + Map tx = new HashMap(); + tx.put("from", accounts.get(0)); + tx.put("to", to); + tx.put("value", wei); + return (String) rpc("eth_sendTransaction", tx); + } + + private static long balance(String address) throws Exception { + return Wei.toGwei(Wei.parseQuantity((String) rpc("eth_getBalance", address, "latest"))); + } + + private static Object rpc(String method, Object... params) throws Exception { + Map request = new HashMap(); + request.put("jsonrpc", "2.0"); + request.put("id", Integer.valueOf(1)); + request.put("method", method); + request.put("params", new ArrayList(Arrays.asList(params))); + Web.Result r = Web.postJson(RPC, Json.write(request), null); + Map answer = Json.parseObject(r.getBodyAsString()); + if (answer.get("error") != null) { + throw new AssertionError(method + ": " + answer.get("error")); + } + return answer.get("result"); + } +} diff --git a/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/chain/ChainVectorTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/chain/ChainVectorTest.java new file mode 100644 index 00000000000..065f21ab9fc --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/chain/ChainVectorTest.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.chain; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Known answers for everything between a ledger amount and the bytes a node +/// accepts. The transaction, address and derivation vectors were produced by +/// an independent implementation (the eth-account library) and by the +/// published EIP-55 and BIP-44 examples; signing is deterministic (RFC 6979), +/// so a signed transaction must match byte for byte. +class ChainVectorTest { + private static final String KEY = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + /// BIP-39 seed of "abandon abandon ... about", no passphrase. + private static final String SEED = "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1" + + "9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"; + + @Test + void rlpMatchesTheSpecificationExamples() { + assertEquals("83646f67", hex(Rlp.string(ascii("dog")))); + List catDog = new ArrayList(); + catDog.add(Rlp.string(ascii("cat"))); + catDog.add(Rlp.string(ascii("dog"))); + assertEquals("c88363617483646f67", hex(Rlp.list(catDog))); + assertEquals("80", hex(Rlp.string(new byte[0]))); + assertEquals("c0", hex(Rlp.list(new ArrayList()))); + assertEquals("80", hex(Rlp.quantity(0))); + assertEquals("0f", hex(Rlp.quantity(15))); + assertEquals("820400", hex(Rlp.quantity(1024))); + String lorem = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"; + assertEquals("b838" + hex(ascii(lorem)), hex(Rlp.string(ascii(lorem)))); + } + + @Test + void weiAndGweiConvertExactlyAndRoundDown() { + assertEquals("14d1120d7b160000", hex(Wei.fromGwei(1500000000L))); + assertEquals(1500000000L, Wei.toGwei(Wei.fromGwei(1500000000L))); + byte[] dusty = Wei.parseQuantity("0x14d1120d7b160001"); + assertEquals(1500000000L, Wei.toGwei(dusty)); + assertTrue(Wei.hasDust(dusty)); + assertEquals(1500000001L, Chain.ceilGwei(dusty)); + assertEquals("0x0", Wei.quantity(0)); + assertEquals("0x539", Wei.quantity(1337)); + assertEquals(1337L, Wei.parseLong("0x539")); + assertThrows(ArithmeticException.class, () -> Wei.toGwei(Hex32.decode("ffffffffffffffffffffffffffffffff"))); + assertEquals("0202", hex(Chain.add(Hex32.decode("ff"), Hex32.decode("0103")))); + } + + @Test + void addressesFollowEip55() { + String[] good = { + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359", + "0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB", + "0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb", + }; + for (String a : good) { + assertEquals(a, EthAddress.checksum(a.toLowerCase())); + assertTrue(EthAddress.valid(a), a); + } + // One character's case flipped: the checksum catches it. + assertFalse(EthAddress.valid("0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAeD")); + // No checksum at all is accepted, as wallets do. + assertTrue(EthAddress.valid("0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed")); + assertFalse(EthAddress.valid("0x5aaeb6053f3e94c9b9a09f33669435e7ef1beae")); + assertFalse(EthAddress.valid("5aaeb6053f3e94c9b9a09f33669435e7ef1beaed00")); + assertFalse(EthAddress.valid("0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaeg")); + } + + @Test + void aKeyHasItsKnownAddress() { + assertEquals("0x2c7536e3605d9c16a7a3d7b1898e529396a65c23", + EthAddress.of(EthKeys.publicKey(Hex32.decode(KEY)))); + } + + @Test + void anEip1559TransferSignsToTheKnownBytes() { + Eip1559Tx tx = new Eip1559Tx(1337, 3, Wei.fromGwei(2), Wei.fromGwei(100), 21000, + "0x3535353535353535353535353535353535353535", Wei.fromGwei(1500000000L), null); + assertEquals("0b1bd411c9eb4da68dee78e282b1a66831167368952179f857f69a680f642cba", + hex(tx.signingHash())); + byte[] signature = EthKeys.sign(Hex32.decode(KEY), tx.signingHash()); + byte[] raw = tx.raw(signature); + assertEquals("02f87582053903847735940085174876e8008252089435353535353535353535353535353535" + + "353535358814d1120d7b16000080c001a0c34cfde89d316325c60fc29e10403b382c28543668c155" + + "2957112c5496125a30a0453e3bcbf326db30b59ea3015c8faf5c190800bddf6f53c83d1967df5c06" + + "4dbc", hex(raw)); + assertEquals("0xf0a0c01af56fcf57face413edf956894ebd29855b1143abde08823d6b4827b8e", + Eip1559Tx.hash(raw)); + // The signature names its signer: what a node checks. + assertArrayEquals(EthKeys.publicKey(Hex32.decode(KEY)), EthKeys.recover(tx.signingHash(), signature)); + } + + @Test + void depositAndHotKeysDeriveAsStandardWalletsDo() { + byte[] seed = Hex32.decode(SEED); + Bip32 first = Bip32.ethereum(seed, 0); + assertEquals("1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727", + hex(first.privateKey())); + assertEquals("0x9858effd232b4033e47d90003d41ec34ecaeda94", first.address()); + SeedSigner signer = new SeedSigner(seed); + assertEquals("0x593814d3309e2df31d112824f0bb5aa7cb0d7d47", signer.depositAddress(7)); + assertEquals("0x78839f6054d7ed13918bae0473ba31b1ca9d7265", signer.hotAddress()); + } + + @Test + void aTransactionRefusesBadFields() { + assertThrows(IllegalArgumentException.class, () -> new Eip1559Tx(0, 0, new byte[0], + new byte[0], 21000, "0x3535353535353535353535353535353535353535", new byte[0], null)); + assertThrows(IllegalArgumentException.class, () -> new Eip1559Tx(1, 0, new byte[0], + new byte[0], 21000, "0x35", new byte[0], null)); + Eip1559Tx tx = new Eip1559Tx(1, 0, new byte[0], new byte[0], 21000, + "0x3535353535353535353535353535353535353535", new byte[0], null); + byte[] bad = new byte[65]; + bad[64] = 2; + assertThrows(IllegalArgumentException.class, () -> tx.raw(bad)); + assertThrows(IllegalArgumentException.class, () -> new EthRpc("http://node.example:8545")); + } + + private static byte[] ascii(String text) { + byte[] out = new byte[text.length()]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) text.charAt(iter); + } + return out; + } + + private static String hex(byte[] bytes) { + return Hex32.encode(bytes); + } +} diff --git a/scripts/bastion/check-template.sh b/scripts/bastion/check-template.sh new file mode 100755 index 00000000000..1ef765bbb48 --- /dev/null +++ b/scripts/bastion/check-template.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Builds what the Initializr generates from this project. +# +# check-template.sh [work-directory] +# +# The Initializr offers this application as a template, from resources that +# scripts/sync-initializr-template.py derives from it. That the resources match +# is one question; this is the other: whether a project generated from them, +# under another name and in another package, still builds. The real generator +# makes the project, and it is then built against this checkout: +# +# the server's tests pass against the contract module +# the app builds from common, without platform modules and with them, and +# the client half of the contract is generated into it +# +# Needs this checkout's artifacts installed (core, javase, css-compiler, the +# Maven plugin, backend, backend-test), a JDK 17 or newer as JAVA_HOME, python3 +# and unzip. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +MVN="${MVN:-mvn}" +WORK="${1:-$HERE/target/template-check}" + +log() { echo "[bastion-template] $*" >&2; } + +python3 "$ROOT/scripts/sync-initializr-template.py" bastion + +# The version this checkout installs, which the generated poms are pointed at: +# the release the Initializr generates against does not have this project's +# build support until one that follows this change. +VERSION="$(sed -n 's:.*\(.*\).*:\1:p' "$ROOT/maven/pom.xml" | head -n 1)" +[ -n "$VERSION" ] || { log "could not read the version from maven/pom.xml"; exit 1; } + +rm -rf "$WORK" +mkdir -p "$WORK" +log "generating the Initializr's downloads" +python3 "$ROOT/scripts/tests/generate-initializr-fixtures.py" "$WORK/fixtures" > "$WORK/fixtures.log" 2>&1 \ + || { tail -n 60 "$WORK/fixtures.log" >&2; log "the generator failed"; exit 1; } + +# extract -- a generated project, pointed at this checkout's version +extract() { + local dir="$WORK/$1" + mkdir -p "$dir" + unzip -q "$WORK/fixtures/BASTION-$1-INTELLIJ.zip" -d "$dir" + python3 - "$dir/pom.xml" "$VERSION" <<'PY' +import re, sys +path, version = sys.argv[1], sys.argv[2] +text = open(path, encoding='utf-8').read() +for tag in ('cn1.plugin.version', 'cn1.version'): + text, count = re.subn(r'<%s>[^<]*' % (tag, tag), '<%s>%s' % (tag, version, tag), text) + assert count == 1, (tag, count) +open(path, 'w', encoding='utf-8').write(text) +PY + echo "$dir" +} + +# The fixtures are generated as com.example.probe.LauncherProbe. +PACKAGE=com/example/probe + +minimal="$(extract APP_WITH_BACKEND)" +log "running the generated server's tests" +(cd "$minimal" && "$MVN" -B -q -Dcodename1.platform=backend -pl shared,backend -am test) +ls "$minimal"/backend/target/surefire-reports/TEST-*.xml >/dev/null 2>&1 \ + || { log "the generated server's tests did not run"; exit 1; } +[ -f "$minimal/backend/target/generated-sources/cn1-annotations/$PACKAGE/api/BankingApiServer.java" ] \ + || { log "the server's half of the contract was not generated"; exit 1; } + +for layout in APP_WITH_BACKEND FULL; do + project="$WORK/$layout" + [ -d "$project" ] || project="$(extract "$layout")" + log "building the generated app ($layout)" + (cd "$project" && "$MVN" -B -q -Dcodename1.platform=javase -Djava.awt.headless=true -DskipTests package) + for built in "classes/theme.res" "classes/$PACKAGE/api/BankingApiImpl.class" "classes/cn1app/RestClientBootstrap.class"; do + [ -f "$project/common/target/$built" ] \ + || { log "$layout: common/target/$built was not built"; exit 1; } + done +done +log "passed" diff --git a/scripts/bastion/common/codenameone_settings.properties b/scripts/bastion/common/codenameone_settings.properties new file mode 100644 index 00000000000..be4045ce9c9 --- /dev/null +++ b/scripts/bastion/common/codenameone_settings.properties @@ -0,0 +1,56 @@ +codename1.android.keystore= +codename1.android.keystoreAlias= +codename1.android.keystorePassword= +codename1.arg.and.themeMode=modern +# No usesCleartextTraffic here, unlike Savor: a bank never talks plain HTTP. +# The development server on this machine is reached anyway because the +# simulator is not Android; a phone on the same network needs the server +# behind TLS (see AppConfig and net/Guard.java for the localhost exemption). +codename1.arg.desktop.interactiveScrollbars=true +codename1.arg.desktop.titleBar=native +codename1.arg.ios.newStorageLocation=true +# What iOS shows before Face ID unlocks the app and the keys it keeps. +codename1.arg.ios.NSFaceIDUsageDescription=Authenticate to unlock Bastion and the keys that sign your payments +codename1.arg.ios.themeMode=modern +codename1.arg.java.version=17 +codename1.arg.nativeTheme=native +# +# Hardening. Every line below takes effect only in a build made by the +# Codename One build server (App Hardening is an Enterprise transform); a +# local build and the simulator ignore them, and the Security Center screen +# says so (com.codename1.security.hardening.Hardening reports what the build +# server actually stamped, not what is asked for here). +# +# Renames classes, encrypts strings and flattens control flow: +# off, standard, aggressive or paranoid. +codename1.arg.cn1.hardenLevel=aggressive +# Exit on launch on a jailbroken iPhone. A bank refuses to run there at all; +# Android's rooted devices are reported to the server instead (DeviceIntegrity +# and the device registration's "rooted" flag), which refuses them money. +codename1.arg.ios.detectJailbreak=true +# The app switcher shows a blank card, not the last balance on screen. +codename1.arg.ios.blockScreenshotsOnEnterBackground=true +# Play Integrity tokens for the device registration. The server verifies them +# (money/IntegrityVerifier); until it has Google credentials it runs the +# simulated verifier, which trusts the flags the app reports. +codename1.arg.android.playIntegrity=true +codename1.cssTheme=true +codename1.displayName=Bastion +codename1.icon=icon.png +codename1.ios.appid=Q5GHSKAL2F.com.codenameone.examples.bastion +codename1.ios.certificate= +codename1.ios.certificatePassword= +codename1.ios.debug.certificate= +codename1.ios.debug.certificatePassword= +codename1.ios.debug.provision= +codename1.ios.provision= +codename1.ios.release.certificate= +codename1.ios.release.certificatePassword= +codename1.ios.release.provision= +codename1.kotlin=false +codename1.languageLevel=5 +codename1.mainName=Bastion +codename1.packageName=com.codenameone.examples.bastion +codename1.secondaryTitle=Bastion +codename1.vendor=CodenameOne +codename1.version=1.0 diff --git a/scripts/bastion/common/icon.png b/scripts/bastion/common/icon.png new file mode 100644 index 00000000000..ee99929845f Binary files /dev/null and b/scripts/bastion/common/icon.png differ diff --git a/scripts/bastion/common/pom.xml b/scripts/bastion/common/pom.xml new file mode 100644 index 00000000000..5df47fa03b5 --- /dev/null +++ b/scripts/bastion/common/pom.xml @@ -0,0 +1,1375 @@ + + + 4.0.0 + + com.codenameone.examples.bastion + bastion + 1.0-SNAPSHOT + + com.codenameone.examples.bastion + bastion-common + 1.0-SNAPSHOT + jar + + + + + com.codenameone + codenameone-core + provided + + + + ${project.groupId} + bastion-shared + ${project.version} + + + + + com.codenameone + codenameone-javase + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + validate + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + android-compat + + + ${basedir}/src/main/android + + + + + com.codenameone + codenameone-android-compat + provided + + + + + + kotlin + + + + ${basedir}/src/main/kotlin + + + + 2.2.10 + true + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + + org.jetbrains + annotations + 13.0 + + + com.codenameone + java-runtime + provided + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + process-resources + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + ${project.basedir}/src/main/android/kotlin + ${project.basedir}/src/main/android/java + ${project.build.directory}/generated-sources/android + + + -no-reflect + -no-jdk + + + + + test-compile + process-test-sources + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + -no-reflect + -no-jdk + + + + + + + + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + cn1-javase-natives + + + ${basedir}/../javase/pom.xml + + + + + + com.codenameone + codenameone-maven-plugin + + + compile-javase-natives + process-classes + + compile-javase-natives + + + javase + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + org.apache.maven.surefire + surefire-junit-platform + 3.0.0-M5 + + + + ${skipTests} + + ${project.build.directory}/cn1-javase/classes + + + + + + + + + cn1-host-javase + + + codename1.platform + javase + + + ${basedir}/../javase/pom.xml + + + + javase + javase + none + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javase + validate + + hosted-platform + + + javase + + + + build-javase + package + + build + + + javase + + + + + + + + + + cn1-host-android + + + codename1.platform + android + + + ${basedir}/../android/pom.xml + + + + android + android + android-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-android + validate + + hosted-platform + + + android + + + + build-android + package + + build + + + android + + + + + + + + + + cn1-host-ios + + + codename1.platform + ios + + + ${basedir}/../ios/pom.xml + + + + ios + ios + ios-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-ios + validate + + hosted-platform + + + ios + + + + build-ios + package + + build + + + ios + + + + + + + + + + cn1-host-javascript + + + codename1.platform + javascript + + + ${basedir}/../javascript/pom.xml + + + + javascript + javascript + local-javascript + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javascript + validate + + hosted-platform + + + javascript + + + + build-javascript + package + + build + + + javascript + + + + + + + + + + cn1-host-win + + + codename1.platform + win + + + ${basedir}/../win/pom.xml + + + + win + win + windows-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-win + validate + + hosted-platform + + + win + + + + build-windows + package + + build + + + win + + + + + + + + + + cn1-host-linux + + + codename1.platform + linux + + + ${basedir}/../linux/pom.xml + + + + linux + linux + linux-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-linux + validate + + hosted-platform + + + linux + + + + build-linux + package + + build + + + linux + + + + + + + + + + simulator + + javase + com.codename1.impl.javase.Simulator + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + ${codename1.exec.args.debug} + ${codename1.exec.args.runjdwp.transport} + + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + idea-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator-idea + verify + + java + + + ${cn1.hosted.javase.skip} + + ${project.build.directory}/cn1-javase/classes + + + ${codename1.mainClass} + + + + codename1.css.compiler.args.input + ${codename1.css.compiler.args.input} + + + codename1.css.compiler.args.output + ${codename1.css.compiler.args.output} + + + codename1.css.compiler.args.merge + ${codename1.css.compiler.args.merge} + + + + cn1.class.path + ${cn1.class.path} + + + + + + + + + + + + debug-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=n,address=${jpda.address} + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + debug-eclipse + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=y,address=${jpda.address},suspend=y + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + run-desktop + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-desktop + verify + + java + + + ${cn1.hosted.javase.skip} + ${codename1.packageName}.${codename1.mainName}Stub + + + ${project.build.directory}/cn1-javase/classes + ${project.build.directory}/cn1-javase/desktop + ${basedir}/src/desktop/resources + ${basedir}/../javase/src/desktop/resources + + + + + + + + + + + executable-jar + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + javase-executable-jar + package + + javase-executable-jar + + + javase + + + + + + + + + + test + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + cn1-tests + test + + test + + + javase + + + + + + + + + + run-android + + + + com.codenameone + codenameone-maven-plugin + + + + run-android-hosted + validate + + hosted-platform + + + android + + + + + + maven-antrun-plugin + + + adb-install + verify + + run + + + ${cn1.hosted.android.skip} + + Running adb install + + + + + + Trying to start app on device using adb + + + + + + + + + + + + + + + + + + + + ios-debug + + + iphone + + + ios + + + + + ios-release + + + iphone + true + + + ios + true + + + + + javascript + + javascript + javascript + + + + + android + + android + android + + + + + win + + windows + win + + + + + windows + + desktop_windows + javase + + + + + mac + + desktop_macosx + javase + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + + com.codenameone + codenameone-maven-plugin + + + + + transcode-svg + generate-sources + + transcode-svg + + + + + compile-android-res + generate-sources + + compile-android-res + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + + remap-android + bytecode-compliance + css + + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + + + + + + diff --git a/scripts/bastion/common/src/desktop/resources/NativeTheme.res b/scripts/bastion/common/src/desktop/resources/NativeTheme.res new file mode 100644 index 00000000000..83e067b6969 Binary files /dev/null and b/scripts/bastion/common/src/desktop/resources/NativeTheme.res differ diff --git a/scripts/bastion/common/src/main/css/theme.css b/scripts/bastion/common/src/main/css/theme.css new file mode 100644 index 00000000000..f1e4d8454e8 --- /dev/null +++ b/scripts/bastion/common/src/main/css/theme.css @@ -0,0 +1,1075 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +/* + * The whole look of the app. + * + * COLOURS AND SHAPE -- change these and nothing else to re-skin the app. Each + * colour has a second value for dark mode, used by the block at the end. + */ +#Constants { + includeNativeBool: true; + defaultSourceDPIInt: "0"; + device-desktop-font-scale: "1.25"; + endsWith3PointsBool: true; + transitionSpeedInt: "200"; + + --brand: #1f3a8a; + --brand-pressed: #2c4fb8; + --brand-contrast: #ffffff; + --surface: #ffffff; + --surface-alt: #f1f3f7; + --surface-press: #e3e7ef; + --text: #141821; + --text-muted: #5d6576; + --line: #dde1e8; + --success: #12805c; + --warning: #9a5a00; + --danger: #c5292a; + --success-tint: #e4f4ed; + --warning-tint: #fcf0df; + --danger-tint: #fcebeb; + + --brand-dark: #8fa8ff; + --brand-pressed-dark: #6f8cf0; + --brand-contrast-dark: #0b1330; + --surface-dark: #11141b; + --surface-alt-dark: #1c2130; + --surface-press-dark: #2a3144; + --text-dark: #e6e9f0; + --text-muted-dark: #a7aebd; + --line-dark: #2e3547; + --success-dark: #47c79a; + --warning-dark: #f0a24b; + --danger-dark: #ff7a7a; + --success-tint-dark: #15302a; + --warning-tint-dark: #33270f; + --danger-tint-dark: #3a1f22; + + --radius: 2mm; + + /* The space between two buttons side by side (Ui.across), in mm: the + same as the vertical space between two stacked ones. */ + bnButtonGapMm: "2"; +} + +/* ---------------------------------------------------------------- surfaces */ + +BnForm { + background-color: var(--surface); + padding: 0; + margin: 0; +} + +BnPage { + background-color: var(--surface); + padding: 3mm 3.6mm 4mm 3.6mm; + margin: 0; +} + +BnRow { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 0.8mm 0 0.8mm 0; +} + +BnSpacer { + background-color: transparent; + padding: 1mm; + margin: 0; +} + +/* -------------------------------------------------------------------- text */ + +BnTitle { + color: var(--text); + font-family: "native:MainBold"; + font-size: 4.4mm; + padding: 1mm 0 1mm 0; + margin: 0; + background-color: transparent; +} + +BnText { + color: var(--text); + font-family: "native:MainRegular"; + font-size: 2.75mm; + padding: 0.4mm 0 0.4mm 0; + margin: 0; + background-color: transparent; +} + +BnMuted { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0.4mm 0 0.4mm 0; + margin: 0; + background-color: transparent; +} + +BnLabel { + color: var(--text-muted); + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 2.6mm 0 1mm 0.3mm; + margin: 0; + background-color: transparent; +} + +BnError { + color: var(--danger); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 1mm 0 1mm 0; + margin: 0; + background-color: transparent; +} + +BnRowTitle { + color: var(--text); + font-family: "native:MainBold"; + font-size: 2.75mm; + padding: 0.4mm 0 0.4mm 0; + margin: 0; + background-color: transparent; +} + +BnRowDetail { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.3mm; + padding: 0.4mm 0 0 0; + margin: 0; + background-color: transparent; +} + +/* ----------------------------------------------------------------- badges */ + +BnBadgeGood { + color: var(--success); + background-color: var(--success-tint); + border-radius: 2mm; + font-family: "native:MainBold"; + font-size: 1.9mm; + padding: 0.5mm 1.8mm 0.5mm 1.8mm; + margin: 0 0 0 1.6mm; +} + +BnBadgeWarn { + color: var(--warning); + background-color: var(--warning-tint); + border-radius: 2mm; + font-family: "native:MainBold"; + font-size: 1.9mm; + padding: 0.5mm 1.8mm 0.5mm 1.8mm; + margin: 0 0 0 1.6mm; +} + +BnBadgeBad { + color: var(--danger); + background-color: var(--danger-tint); + border-radius: 2mm; + font-family: "native:MainBold"; + font-size: 1.9mm; + padding: 0.5mm 1.8mm 0.5mm 1.8mm; + margin: 0 0 0 1.6mm; +} + +/* ---------------------------------------------------------------- controls */ + +BnPrimary { + color: var(--brand-contrast); + background-color: var(--brand); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.5mm; + text-align: center; + padding: 2.6mm 3.2mm 2.6mm 3.2mm; + margin: 1mm 0 1mm 0; +} + +BnPrimary.pressed { + background-color: var(--brand-pressed); +} + +BnPrimary.disabled { + color: var(--text-muted); + background-color: var(--surface-press); +} + +BnSecondary { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.5mm; + text-align: center; + padding: 2.6mm 3.2mm 2.6mm 3.2mm; + margin: 1mm 0 1mm 0; +} + +BnSecondary.pressed { + background-color: var(--surface-press); +} + +BnField { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainRegular"; + font-size: 2.9mm; + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 1mm 0 1mm 0; +} + +/* ---------------------------------------------------------------- money */ + +BnHero { + background-color: var(--brand); + border-radius: var(--radius); + padding: 3.6mm 4mm 3.6mm 4mm; + margin: 1mm 0 2mm 0; +} + +BnHeroLabel { + color: var(--brand-contrast); + font-family: "native:MainRegular"; + font-size: 2.6mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +BnHeroAmount { + color: var(--brand-contrast); + font-family: "native:MainBold"; + font-size: 6.4mm; + padding: 0.8mm 0 0.8mm 0; + margin: 0; + background-color: transparent; +} + +BnHeroNote { + color: var(--brand-contrast); + font-family: "native:MainLight"; + font-size: 2.3mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +BnRowValue { + color: var(--text); + font-family: "native:MainBold"; + font-size: 2.8mm; + text-align: right; + padding: 0.4mm 0 0.4mm 2mm; + margin: 0; + background-color: transparent; +} + +BnRowValueMuted { + color: var(--text-muted); + font-family: "native:MainBold"; + font-size: 2.8mm; + text-align: right; + padding: 0.4mm 0 0.4mm 2mm; + margin: 0; + background-color: transparent; +} + +BnRowValueMono { + color: var(--text); + font-family: "native:MainRegular"; + font-size: 2.2mm; + text-align: right; + padding: 0 0 0 2mm; + margin: 0; + background-color: transparent; +} + +BnAmountIn { + color: var(--success); + font-family: "native:MainBold"; + font-size: 2.8mm; + text-align: right; + padding: 0.4mm 0 0.4mm 2mm; + margin: 0; + background-color: transparent; +} + +BnAmountOut { + color: var(--text); + font-family: "native:MainBold"; + font-size: 2.8mm; + text-align: right; + padding: 0.4mm 0 0.4mm 2mm; + margin: 0; + background-color: transparent; +} + +BnRowLead { + background-color: transparent; + padding: 0; + margin: 0; +} + +BnEmpty { + color: var(--text-muted); + font-family: "native:MainLight"; + font-size: 2.6mm; + text-align: center; + padding: 3mm 2mm 3mm 2mm; + margin: 0; + background-color: transparent; +} + +BnLoadingCaption { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.2mm; + padding: 0.6mm 0 2mm 0; + margin: 0; + background-color: transparent; +} + +BnSkeletonCard { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.6mm 3mm 2.6mm 3mm; + margin: 1mm 0 1mm 0; +} + +BnSkeletonBar { + background-color: var(--surface-press); + border-radius: 1mm; + padding: 0; + margin: 0.6mm 0 0.6mm 0; +} + +BnPanel { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 1mm 0 1mm 0; +} + +BnBanner { + background-color: var(--warning-tint); + border-radius: var(--radius); + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 1mm 0 2mm 0; +} + +BnBannerTitle { + color: var(--warning); + font-family: "native:MainBold"; + font-size: 2.8mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +BnBannerText { + color: var(--text); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0.5mm 0 0.5mm 0; + margin: 0; + background-color: transparent; +} + +/* ------------------------------------------------------------- segments */ + +BnSegments { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 0.6mm; + margin: 1mm 0 1mm 0; +} + +BnSegment { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.6mm; + text-align: center; + padding: 1.8mm 1mm 1.8mm 1mm; + margin: 0; +} + +BnSegmentOn { + color: var(--brand-contrast); + background-color: var(--brand); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.6mm; + text-align: center; + padding: 1.8mm 1mm 1.8mm 1mm; + margin: 0; +} + +BnLink { + color: var(--brand); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.6mm; + padding: 1mm 0 1mm 2mm; + margin: 0; +} + +/* ---------------------------------------------------------------- cards */ + +/* The face is always a dark card, in either mode: its type is white. */ +BnCardBrand { + color: #ffffff; + font-family: "native:MainBold"; + font-size: 3mm; + padding: 0 0 3mm 0; + margin: 0; + background-color: transparent; +} + +BnCardNumber { + color: #ffffff; + font-family: "native:MainRegular"; + font-size: 3.6mm; + padding: 0 0 1mm 0; + margin: 0; + background-color: transparent; +} + +/* The card face (CardFace) paints itself; these give it its colours and + type: the gradient runs from BnCardFace's foreground to BnCardFaceEnd's. */ +BnCardFace { color: #2747a8; background-color: transparent; padding: 0; margin: 2mm 0 2mm 0; } +BnCardFaceEnd { color: #0f1d4d; background-color: transparent; } +BnCardFaceFrozen { color: #8a93a6; background-color: transparent; } +BnCardFaceFrozenEnd { color: #4b5263; background-color: transparent; } +BnCardChip { color: #d9b45a; background-color: transparent; } +BnCardCaption { + color: #c9d3f2; + font-family: "native:MainRegular"; + font-size: 1.7mm; + padding: 0; + margin: 0; + background-color: transparent; +} +BnCardName { + color: #ffffff; + font-family: "native:MainBold"; + font-size: 2.6mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +/* How much of the daily limit is used: a bar in a track. */ +BnMeter { color: var(--brand); background-color: var(--surface-press); border-radius: 1mm; padding: 0; margin: 1mm 0 0.4mm 0; } +BnMeterHigh { color: var(--danger); background-color: var(--surface-press); border-radius: 1mm; padding: 0; margin: 1mm 0 0.4mm 0; } + +/* A fact whose value may be long (a reference): the label above, the value + wrapping under it. */ +/* The copy button beside a value (Ui.copyable): an icon, no box. */ +BnIconButton { + color: var(--brand); + background-color: transparent; + padding: 1.6mm 1.6mm 1.6mm 1.6mm; + margin: 0 0 0 1mm; +} + +BnIconButton.pressed { + background-color: var(--surface-press); + border-radius: var(--radius); +} + +BnRowValueWrap { + color: var(--text); + font-family: "native:MainRegular"; + font-size: 2.4mm; + padding: 0.6mm 0 0 0; + margin: 0; + background-color: transparent; +} + +/* The block at the top of a statement: account, period and totals. */ +BnStatementHead { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 0 0 1.6mm 0; +} + +BnCheck { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.8mm; + padding: 1.6mm 1mm 1.6mm 1mm; + margin: 0; +} + +/* ------------------------------------------------------------- exchange */ + +BnBookRow { + padding: 0.5mm 1mm 0.5mm 1mm; + margin: 0; + background-color: transparent; +} + +BnAsk { + color: var(--danger); + font-family: "native:MainBold"; + font-size: 2.6mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +BnBid { + color: var(--success); + font-family: "native:MainBold"; + font-size: 2.6mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +BnBookQty { + color: var(--text); + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: right; + padding: 0; + margin: 0; + background-color: transparent; +} + +/* ----------------------------------------------------------- onboarding */ + +BnRule { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.4mm; + padding: 0.3mm 0 0.3mm 0; + margin: 0; + background-color: transparent; +} + +BnRuleMet { + color: var(--success); + font-family: "native:MainRegular"; + font-size: 2.4mm; + padding: 0.3mm 0 0.3mm 0; + margin: 0; + background-color: transparent; +} + +BnSecret { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.2mm; + text-align: center; + padding: 1mm 0 1mm 0; + margin: 0; + background-color: transparent; +} + +/* One group of four characters of a key: monospaced (Ui.monospace sets the + face), so a key copied by eye cannot mistake one character's width for + another's. */ +BnKeyGroup { + color: var(--text); + background-color: var(--surface-alt); + border-radius: 1mm; + font-size: 3.2mm; + text-align: center; + padding: 1mm 1.4mm 1mm 1.4mm; + margin: 0.8mm; +} + +/* Always white: a QR code in dark-mode colours does not scan. */ +BnQr { + background-color: #ffffff; + padding: 0; + margin: 2mm 0 2mm 0; +} + +BnMenuHeader { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.2mm; + padding: 4mm 3mm 3mm 3mm; + margin: 0; + background-color: transparent; +} + +/* --------------------------------------------------------------- title bar */ + +/* The strip the clock and the battery are drawn over: the colour of the + title bar under it, so the two read as one band. */ +StatusBar { + background-color: var(--surface); + padding: 0; + margin: 0; +} + +Toolbar { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 1.2mm 1mm 1.2mm 1mm; + margin: 0; +} + +Title { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.6mm; + text-align: center; + padding: 1.4mm 1mm 1.4mm 1mm; + margin: 0; + background-color: transparent; +} + +TitleCommand { + color: var(--text); + font-size: 3.4mm; + padding: 1.4mm 2mm 1.4mm 2mm; + margin: 0; + background-color: transparent; +} + +BackCommand { + color: var(--text); + padding: 1.4mm 2mm 1.4mm 2mm; + margin: 0; + background-color: transparent; +} + +SideNavigationPanel { + background-color: var(--surface); + padding: 0; + margin: 0; +} + +SideCommand { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.9mm; + padding: 2.6mm 4mm 2.6mm 4mm; + margin: 0; + border-bottom: 1px solid var(--line); +} + +/* The front door: sign in, the code step and the lock have no title bar; + this band is their header. */ +BnBrandBand { + background-color: var(--brand); + padding: 9mm 5mm 6mm 5mm; + margin: 0; +} + +BnBrandLogo { + color: var(--brand-contrast); + background-color: transparent; + padding: 0 0 2mm 0; + margin: 0; +} + +BnBrandTitle { + color: var(--brand-contrast); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 5.6mm; + padding: 0; + margin: 0; +} + +BnBrandText { + color: var(--brand-contrast); + background-color: transparent; + font-family: "native:MainLight"; + font-size: 3mm; + padding: 0.6mm 0 0 0; + margin: 0; +} + +BnLandingHeading { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 4mm; + padding: 2mm 0 2mm 0; + margin: 0; +} + +/* ------------------------------------------------------------------ charts */ + +/* Charts take their colours from the foreground of these styles. */ +BnChartLine { color: var(--brand); background-color: transparent; } +BnChartIn { color: var(--success); background-color: transparent; } +BnChartOut { color: var(--danger); background-color: transparent; } +BnChartGrid { color: #c3c9d5; background-color: transparent; } +BnChartText { color: var(--text-muted); background-color: transparent; } +BnChartSlice1 { color: var(--brand); background-color: transparent; } +BnChartSlice2 { color: #f2a900; background-color: transparent; } +BnChartSlice3 { color: #6c4bd8; background-color: transparent; } +BnChartSlice4 { color: #12a17a; background-color: transparent; } +BnChartSlice5 { color: #e0557a; background-color: transparent; } + +BnChart { + background-color: transparent; + padding: 0; + margin: 0; +} + +BnChartBox { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 1.6mm; + margin: 0.8mm 0 1.2mm 0; +} + +BnChartCaption { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.2mm; + padding: 1mm 0 0 0.3mm; + margin: 0; +} + +BnSwatch { + background-color: var(--brand); + border-radius: 0.6mm; + font-size: 1.6mm; + padding: 0.6mm 1.4mm 0.6mm 1.4mm; + margin: 1.4mm 1.4mm 1.4mm 1.4mm; +} + +BnLegend { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0.8mm 2mm 0.8mm 0; + margin: 0; +} + +BnLegendValue { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.5mm; + padding: 0.8mm 0 0.8mm 1mm; + margin: 0; +} + +/* ----------------------------------------------------------------- console */ + +BnSide { + background-color: var(--surface-alt); + border-right: 1px solid var(--line); + padding: 3mm 2mm 3mm 2mm; + margin: 0; +} + +BnSideBrand { + color: var(--brand); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.6mm; + padding: 1mm 2mm 3mm 2mm; + margin: 0; +} + +BnSideItem { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: left; + border-radius: var(--radius); + padding: 1.8mm 2.6mm 1.8mm 2mm; + margin: 0.2mm 0 0.2mm 0; +} + +BnSideItemOn { + color: var(--brand-contrast); + background-color: var(--brand); + font-family: "native:MainBold"; + font-size: 2.6mm; + text-align: left; + border-radius: var(--radius); + padding: 1.8mm 2.6mm 1.8mm 2mm; + margin: 0.2mm 0 0.2mm 0; +} + +BnConsolePage { + background-color: var(--surface); + padding: 3mm 5mm 5mm 5mm; + margin: 0; +} + +BnConsoleTitle { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 5mm; + padding: 1mm 0 2mm 0; + margin: 0; +} + +BnSubTitle { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.4mm; + padding: 2mm 0 1mm 0; + margin: 0; +} + +BnPanelTitle { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.6mm; + padding: 0 0 1mm 0; + margin: 0; +} + +BnTile { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.6mm 3mm 2.6mm 3mm; + margin: 0.8mm; +} + +BnTileValue { color: var(--text); background-color: transparent; font-family: "native:MainBold"; font-size: 3.2mm; padding: 0; margin: 0; } +BnTileValueGood { color: var(--success); background-color: transparent; font-family: "native:MainBold"; font-size: 3.2mm; padding: 0; margin: 0; } +BnTileValueBad { color: var(--danger); background-color: transparent; font-family: "native:MainBold"; font-size: 3.2mm; padding: 0; margin: 0; } +BnTileValueWarn { color: var(--warning); background-color: transparent; font-family: "native:MainBold"; font-size: 3.2mm; padding: 0; margin: 0; } +BnTileLabel { color: var(--text-muted); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.3mm; padding: 0.6mm 0 0 0; margin: 0; } + +BnTable { + background-color: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0; + margin: 1mm 0 2mm 0; +} + +BnTableHead { + background-color: var(--surface-alt); + border-bottom: 1px solid var(--line); + padding: 1.4mm 1.4mm 1.4mm 1.4mm; + margin: 0; +} + +BnTableRow { + background-color: transparent; + border-bottom: 1px solid var(--line); + padding: 1.2mm 1.4mm 1.2mm 1.4mm; + margin: 0; +} + +BnTableGroup { + background-color: var(--surface-alt); + border-bottom: 1px solid var(--line); + padding: 1.2mm 1.4mm 1.2mm 1.4mm; + margin: 0; +} + +BnTableTotal { + background-color: var(--surface-alt); + border-top: 2px solid var(--text-muted); + padding: 1.4mm 1.4mm 1.4mm 1.4mm; + margin: 0; +} + +BnTableRowBad { + background-color: var(--danger-tint); + border-bottom: 1px solid var(--line); + padding: 1.2mm 1.4mm 1.2mm 1.4mm; + margin: 0; +} + +BnTableRowWarn { + background-color: var(--warning-tint); + border-bottom: 1px solid var(--line); + padding: 1.2mm 1.4mm 1.2mm 1.4mm; + margin: 0; +} + +BnCellHead { color: var(--text-muted); background-color: transparent; font-family: "native:MainBold"; font-size: 2.1mm; padding: 0 1mm 0 1mm; margin: 0; } +BnCellHeadNum { color: var(--text-muted); background-color: transparent; font-family: "native:MainBold"; font-size: 2.1mm; text-align: right; padding: 0 1mm 0 1mm; margin: 0; } +BnCell { color: var(--text); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.3mm; padding: 0 1mm 0 1mm; margin: 0; } +BnCellNum { color: var(--text); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.3mm; text-align: right; padding: 0 1mm 0 1mm; margin: 0; } +BnCellNumOut { color: var(--danger); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.3mm; text-align: right; padding: 0 1mm 0 1mm; margin: 0; } +BnCellMuted { color: var(--text-muted); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.3mm; padding: 0.6mm 1mm 0.6mm 1mm; margin: 0; } + +BnFact { background-color: transparent; padding: 1mm 1mm 1mm 0; margin: 0; } +BnFactLabel { color: var(--text-muted); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.1mm; padding: 0; margin: 0; } +BnFactValue { color: var(--text); background-color: transparent; font-family: "native:MainBold"; font-size: 2.6mm; padding: 0.3mm 0 0 0; margin: 0; } + +/* A T-account: debits on the left of the T, credits on the right (mirrored + in Hebrew, where the debit side is the right). */ +BnTHead { background-color: transparent; border-bottom: 2px solid var(--text); padding: 0; margin: 2mm 0 0 0; } +BnTSide { color: var(--text); background-color: transparent; font-family: "native:MainBold"; font-size: 2.6mm; text-align: center; padding: 1mm; margin: 0; } +BnTDebit { background-color: transparent; border-right: 2px solid var(--text); padding: 1mm 1.4mm 1mm 0; margin: 0; } +BnTCredit { background-color: transparent; padding: 1mm 0 1mm 1.4mm; margin: 0; } +BnTLine { background-color: transparent; padding: 0.6mm 0 0.6mm 0; margin: 0; } +BnTLineTotal { background-color: var(--surface-alt); padding: 0.8mm 0 0.8mm 0; margin: 0; } +BnTFoot { background-color: transparent; border-top: 2px solid var(--text); padding: 0; margin: 0 0 2mm 0; } + +BnCode { + color: var(--text); + background-color: var(--surface-alt); + font-family: "native:MainRegular"; + font-size: 2.1mm; + border-radius: var(--radius); + padding: 2mm; + margin: 1mm 0 1mm 0; +} + +BnAmountNegative { + color: var(--danger); + font-family: "native:MainBold"; + font-size: 2.9mm; + background-color: transparent; + padding: 0; + margin: 0; +} + +/* --------------------------------------------------------------- dark mode */ + +@media (prefers-color-scheme: dark) { + BnForm { background-color: var(--surface-dark); } + BnPage { background-color: var(--surface-dark); } + BnRow { background-color: var(--surface-alt-dark); } + BnTitle { color: var(--text-dark); } + BnText { color: var(--text-dark); } + BnMuted { color: var(--text-muted-dark); } + BnLabel { color: var(--text-muted-dark); } + BnError { color: var(--danger-dark); } + BnRowTitle { color: var(--text-dark); } + BnRowDetail { color: var(--text-muted-dark); } + BnLoadingCaption { color: var(--text-muted-dark); } + BnSkeletonCard { background-color: var(--surface-alt-dark); } + BnSkeletonBar { background-color: var(--surface-press-dark); } + BnBadgeGood { color: var(--success-dark); background-color: var(--success-tint-dark); } + BnBadgeWarn { color: var(--warning-dark); background-color: var(--warning-tint-dark); } + BnBadgeBad { color: var(--danger-dark); background-color: var(--danger-tint-dark); } + BnPrimary { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + BnPrimary.pressed { background-color: var(--brand-pressed-dark); } + BnPrimary.disabled { color: var(--text-muted-dark); background-color: var(--surface-press-dark); } + BnSecondary { color: var(--text-dark); background-color: var(--surface-alt-dark); } + BnSecondary.pressed { background-color: var(--surface-press-dark); } + BnField { color: var(--text-dark); background-color: var(--surface-alt-dark); } + BnHero { background-color: var(--brand-dark); } + BnHeroLabel { color: var(--brand-contrast-dark); } + BnHeroAmount { color: var(--brand-contrast-dark); } + BnHeroNote { color: var(--brand-contrast-dark); } + BnRowValue { color: var(--text-dark); } + BnRowValueMuted { color: var(--text-muted-dark); } + BnRowValueMono { color: var(--text-dark); } + BnAmountIn { color: var(--success-dark); } + BnAmountOut { color: var(--text-dark); } + BnEmpty { color: var(--text-muted-dark); } + BnPanel { background-color: var(--surface-alt-dark); } + BnBanner { background-color: var(--warning-tint-dark); } + BnBannerTitle { color: var(--warning-dark); } + BnBannerText { color: var(--text-dark); } + BnSegments { background-color: var(--surface-alt-dark); } + BnSegment { color: var(--text-muted-dark); } + BnSegmentOn { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + BnLink { color: var(--brand-dark); } + BnKeyGroup { color: var(--text-dark); background-color: var(--surface-alt-dark); } + BnMeter { color: var(--brand-dark); background-color: var(--surface-press-dark); } + BnMeterHigh { color: var(--danger-dark); background-color: var(--surface-press-dark); } + BnRowValueWrap { color: var(--text-dark); } + BnStatementHead { background-color: var(--surface-alt-dark); } + BnCheck { color: var(--text-dark); } + BnAsk { color: var(--danger-dark); } + BnBid { color: var(--success-dark); } + BnBookQty { color: var(--text-dark); } + BnRule { color: var(--text-muted-dark); } + BnRuleMet { color: var(--success-dark); } + BnSecret { color: var(--text-dark); } + BnMenuHeader { color: var(--text-dark); } + StatusBar { background-color: var(--surface-dark); } + Toolbar { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + Title { color: var(--text-dark); } + TitleCommand { color: var(--text-dark); } + BackCommand { color: var(--text-dark); } + SideNavigationPanel { background-color: var(--surface-dark); } + SideCommand { color: var(--text-dark); border-bottom: 1px solid var(--line-dark); } + BnBrandBand { background-color: var(--brand-contrast-dark); } + BnBrandLogo { color: var(--brand-dark); } + BnBrandTitle { color: var(--text-dark); } + BnBrandText { color: var(--text-muted-dark); } + BnLandingHeading { color: var(--text-dark); } + BnChartLine { color: var(--brand-dark); } + BnChartIn { color: var(--success-dark); } + BnChartOut { color: var(--danger-dark); } + BnChartGrid { color: #3a4256; } + BnChartText { color: var(--text-muted-dark); } + BnChartSlice1 { color: var(--brand-dark); } + BnChartBox { background-color: var(--surface-alt-dark); } + BnChartCaption { color: var(--text-muted-dark); } + BnLegend { color: var(--text-dark); } + BnLegendValue { color: var(--text-dark); } + BnSide { background-color: var(--surface-alt-dark); border-right: 1px solid var(--line-dark); } + BnSideBrand { color: var(--brand-dark); } + BnSideItem { color: var(--text-dark); } + BnSideItemOn { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + BnConsolePage { background-color: var(--surface-dark); } + BnConsoleTitle { color: var(--text-dark); } + BnSubTitle { color: var(--text-dark); } + BnPanelTitle { color: var(--text-dark); } + BnTile { background-color: var(--surface-alt-dark); } + BnTileValue { color: var(--text-dark); } + BnTileValueGood { color: var(--success-dark); } + BnTileValueBad { color: var(--danger-dark); } + BnTileValueWarn { color: var(--warning-dark); } + BnTileLabel { color: var(--text-muted-dark); } + BnTable { background-color: var(--surface-dark); border: 1px solid var(--line-dark); } + BnTableHead { background-color: var(--surface-alt-dark); border-bottom: 1px solid var(--line-dark); } + BnTableRow { border-bottom: 1px solid var(--line-dark); } + BnTableGroup { background-color: var(--surface-alt-dark); border-bottom: 1px solid var(--line-dark); } + BnTableTotal { background-color: var(--surface-alt-dark); border-top: 2px solid var(--text-muted-dark); } + BnTableRowBad { background-color: var(--danger-tint-dark); border-bottom: 1px solid var(--line-dark); } + BnTableRowWarn { background-color: var(--warning-tint-dark); border-bottom: 1px solid var(--line-dark); } + BnCellHead { color: var(--text-muted-dark); } + BnCellHeadNum { color: var(--text-muted-dark); } + BnCell { color: var(--text-dark); } + BnCellNum { color: var(--text-dark); } + BnCellMuted { color: var(--text-muted-dark); } + BnFactLabel { color: var(--text-muted-dark); } + BnFactValue { color: var(--text-dark); } + BnTHead { border-bottom: 2px solid var(--text-dark); } + BnTSide { color: var(--text-dark); } + BnTDebit { border-right: 2px solid var(--text-dark); } + BnTLineTotal { background-color: var(--surface-alt-dark); } + BnTFoot { border-top: 2px solid var(--text-dark); } + BnCode { color: var(--text-dark); background-color: var(--surface-alt-dark); } + BnAmountNegative { color: var(--danger-dark); } + BnCellNumOut { color: var(--danger-dark); } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/AppConfig.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/AppConfig.java new file mode 100644 index 00000000000..e50b6489886 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/AppConfig.java @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.ui.CN; +import com.codename1.ui.Display; + +import java.util.Hashtable; +import java.util.Vector; + +/// The few things about the app that are settings and not code -- most of +/// them security settings, and each one read by the Security Center screen so +/// the user sees what is in force. +public final class AppConfig { + /// Where a release talks to when nothing says otherwise. Replace with the + /// deployment's API address; its host must be in [#ALLOWED_HOSTS]. + public static final String RELEASE_SERVER = "https://api.bastion.example"; + /// Where the simulator talks to: a server started on this machine with + /// `cn1:backend`. Only a development run uses it ([#developmentAllowed]). + public static final String DEVELOPMENT_SERVER = "http://localhost:8080"; + /// The id the server knows this app by (`SecurityConfig.CLIENT_ID`). + public static final String CLIENT_ID = "bastion-app"; + /// The redirect the server hands the authorization code back on. The app + /// reads the code off the redirect and never opens it + /// (`SecurityConfig.LOOPBACK_REDIRECT`). + public static final String REDIRECT_URI = "http://127.0.0.1/callback"; + /// The path the server hands the code back on when the app runs in a + /// browser. The same constant is in the server's `SecurityConfig`. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + + /// The hosts the app may talk to at all. Anything else is refused before a + /// connection is opened (see `net/Guard`), so a link, a redirect or a + /// mistyped server cannot carry a token or a signed request elsewhere. + /// Replace with the deployment's API host(s). + public static final String[] ALLOWED_HOSTS = {"api.bastion.example"}; + + /// Certificate pins: host to the base64 SHA-256 digests of the subject + /// public key infos its chain may present -- the server's own key and a + /// backup (an issuing CA, or a second key held offline), so a renewal or + /// a rotation never locks the app out. The placeholder values pin nothing + /// real; put the deployment's in before release. Pins arriving over the + /// air from the attestation service (`AppShield.getPinSet()`) are checked + /// too, on top of these. + /// + /// Compute one with: + /// `openssl s_client -connect host:443 | openssl x509 -pubkey -noout | + /// openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64` + public static final String[][] PINS = { + {"api.bastion.example", + "REPLACE+WITH+THE+SERVERS+OWN+SPKI+SHA256+DIGEST=", + "REPLACE+WITH+A+BACKUP+KEY+OR+ISSUING+CA+DIGEST="}, + }; + + /// How long the app may sit untouched before it locks. + public static final long IDLE_LOCK_MILLIS = 2 * 60 * 1000L; + /// How long the app may be in the background before it asks again. + public static final long BACKGROUND_LOCK_MILLIS = 30 * 1000L; + /// How long a copied account number or crypto address stays on the + /// clipboard. + public static final long CLIPBOARD_CLEAR_MILLIS = 60 * 1000L; + + private AppConfig() { + } + + /// The server's address with no trailing slash: a `bastion.server.url` + /// system property (the tests), then -- in a browser -- the address the + /// page came from, since the server that hosts the app is the server it + /// talks to, then [#DEVELOPMENT_SERVER] in a development run, else + /// [#RELEASE_SERVER]. + public static String serverUrl() { + String url = System.getProperty("bastion.server.url"); + if (url == null || url.length() == 0) { + String origin = pageOrigin(); + url = origin != null ? origin + : developmentAllowed() ? DEVELOPMENT_SERVER : RELEASE_SERVER; + } + return clean(url); + } + + /// Whether this run may use a development server at all: in the + /// simulator, under the tests (which name their server), or in a page + /// served from this machine -- which is itself cleartext from loopback, + /// so talking back to it adds nothing. An installed app on a phone is + /// none of these, so a release build never has the exemption, whatever + /// address it is handed. + public static boolean developmentAllowed() { + String testing = System.getProperty("bastion.server.url"); + if (testing != null && testing.length() > 0) { + return true; + } + if (!Display.isInitialized()) { + return false; + } + if (CN.isSimulator()) { + return true; + } + String origin = pageOrigin(); + return origin != null && isLoopback(hostOf(origin)); + } + + /// The address of the page the app is running in -- `https://host`, with + /// no path -- or null when it is not running in a browser. + public static String pageOrigin() { + if (!Display.isInitialized()) { + return null; + } + String origin = CN.getProperty("browser.window.location.origin", null); + if (origin == null || !(origin.regionMatches(true, 0, "http://", 0, 7) + || origin.regionMatches(true, 0, "https://", 0, 8))) { + return null; + } + return clean(origin); + } + + /// Where the server is asked to send the authorization code. The + /// installed app names the loopback address and reads the code off the + /// redirect without following it. A browser follows a redirect itself and + /// will not show the page an answer from another origin, so there the code + /// is sent to the server the page came from, which hands it back + /// (`SignInCodePage` on the server). + public static String redirectUri() { + String origin = pageOrigin(); + return origin == null ? REDIRECT_URI : origin + WEB_REDIRECT_PATH; + } + + /// Whether `url` is a development server this run may use: one on this + /// machine (`localhost` or a loopback address), in a run where + /// [#developmentAllowed]. The ONE exemption from "no cleartext": a server + /// started with `cn1:backend` has no certificate, and traffic to it never + /// leaves the machine. It is also exempt from the host allow-list and the + /// pins, and the Security Center says so in red while it is in use. + /// + /// The Android emulator's alias for the host machine (`10.0.2.2`) is + /// deliberately not one: that traffic crosses a virtual network into + /// another machine, and an app on an emulator is a release-shaped build. + /// Point an emulator at an `https` address instead. + public static boolean isDevelopmentServer(String url) { + return isLoopback(hostOf(url)) && developmentAllowed(); + } + + /// Whether `host` (lower case) is this machine. + static boolean isLoopback(String host) { + return "localhost".equals(host) || "127.0.0.1".equals(host) || "[::1]".equals(host); + } + + /// Whether `host` (lower case) is one of [#ALLOWED_HOSTS]. + public static boolean isAllowedHost(String host) { + for (String allowed : ALLOWED_HOSTS) { + if (allowed.equals(host)) { + return true; + } + } + return false; + } + + public static boolean isHttps(String url) { + return url != null && url.regionMatches(true, 0, "https://", 0, 8); + } + + /// The host of an `http(s)` URL, in lower case, without the port; null when + /// it is not one. + public static String hostOf(String url) { + if (url == null) { + return null; + } + int start; + if (url.regionMatches(true, 0, "https://", 0, 8)) { + start = 8; + } else if (url.regionMatches(true, 0, "http://", 0, 7)) { + start = 7; + } else { + return null; + } + // The authority runs to the first '/', '?' or '#'. Inside it the host + // follows the LAST '@' (user info may hold ':' and '@' of its own: + // `http://localhost:1@evil.example/` is a request to evil.example), + // and ends at the port's ':' -- after the ']' of an IPv6 literal. + int end = start; + while (end < url.length()) { + char c = url.charAt(end); + if (c == '/' || c == '?' || c == '#' || c == '\\') { + break; + } + end++; + } + String authority = url.substring(start, end); + int at = authority.lastIndexOf('@'); + String host = at >= 0 ? authority.substring(at + 1) : authority; + if (host.startsWith("[")) { + int close = host.indexOf(']'); + if (close < 0 || (close + 1 < host.length() && host.charAt(close + 1) != ':')) { + return null; + } + host = host.substring(0, close + 1); + } else { + int colon = host.indexOf(':'); + if (colon >= 0) { + host = host.substring(0, colon); + } + } + if (host.length() == 0) { + return null; + } + return asciiLower(host); + } + + /// [#PINS] as a host-to-`Vector` table, the shape `PinSet` takes. + public static Hashtable pinTable() { + Hashtable table = new Hashtable(); + for (String[] row : PINS) { + Vector pins = new Vector(); + for (int iter = 1; iter < row.length; iter++) { + pins.addElement(row[iter]); + } + table.put(asciiLower(row[0]), pins); + } + return table; + } + + /// Folded by hand: a host name is ASCII, and `toLowerCase()` follows the + /// device's language (under Turkish, `I` does not fold to `i`). + public static String asciiLower(String text) { + char[] chars = text.toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + char c = chars[iter]; + if (c >= 'A' && c <= 'Z') { + chars[iter] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } + + private static String clean(String url) { + String text = url == null ? "" : url.trim(); + while (text.endsWith("/")) { + text = text.substring(0, text.length() - 1); + } + if (text.length() == 0) { + return developmentAllowed() ? DEVELOPMENT_SERVER : RELEASE_SERVER; + } + if (!text.regionMatches(true, 0, "http://", 0, 7) + && !text.regionMatches(true, 0, "https://", 0, 8)) { + text = "https://" + text; + } + return text; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/Bastion.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/Bastion.java new file mode 100644 index 00000000000..425e77f35ff --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/Bastion.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.system.Lifecycle; +import com.codename1.ui.Form; +import com.codenameone.examples.bastion.money.Amounts; +import com.codenameone.examples.bastion.net.Guard; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.security.DeviceKey; +import com.codenameone.examples.bastion.security.Keys; +import com.codenameone.examples.bastion.security.Lock; +import com.codenameone.examples.bastion.security.Protect; +import com.codenameone.examples.bastion.ui.Lang; +import com.codenameone.examples.bastion.ui.LockForm; +import com.codenameone.examples.bastion.ui.Nav; +import com.codenameone.examples.bastion.ui.SignInForm; +import com.codenameone.examples.bastion.ui.Ui; + +/// The app. Before anything touches the network the guard goes in (no +/// cleartext, a host allow-list, certificate pins, AppShield); then the +/// stored session is picked up behind the lock. +/// +/// Where things are: +/// +/// - `net` -- the guard, the session (password, second factor, passkey) and +/// the typed clients generated from the `shared` contracts. +/// - `security` -- the device signing key, request signing, the app lock, +/// screen and clipboard protection, and the Security Center's status. +/// - `ui` -- the screens. +/// - `AppConfig` -- the server, the allowed hosts, the pins and the timeouts. +public class Bastion extends Lifecycle { + @Override + public void runApp() { + Lang.apply(); + Amounts.useLocale(); + if (!Guard.install()) { + // Fail closed: without the guard nothing would stop cleartext, an + // unlisted host or an unpinned certificate. No request is made. + Form fatal = Ui.form("Bastion"); + fatal.add(Ui.title("Bastion cannot start")); + fatal.add(Ui.text(Lang.tr("The network protection could not be installed, so " + + "Bastion will not connect. Reinstall the app."))); + Ui.show(fatal, false); + return; + } + Protect.install(); + Session.start(AppConfig.serverUrl()); + // A session that ran out while locked is left to the unlock: it reads + // the stored tokens, finds none that work and asks for a sign-in then. + // Showing the sign-in form here would replace the lock screen. + Session.onSignedOut(() -> { + if (!Lock.isLocked()) { + signInScreen(); + } + }); + Nav.install(Bastion::signedOut); + Lock.install(new Lock.Screens() { + @Override + public void showLocked() { + // With a biometric or PIN set up, signing in again gives up + // the keys (LockForm says so). Without one, the password IS + // the unlock, and the same account keeps its device key + // (Keys#claim still starts over for a different account). + LockForm.show(() -> Session.signOut(Keys.protectedByUser() + ? Bastion::signedOut : Bastion::signInAgain)); + } + + @Override + public void showUnlocked(final Form previous) { + // Locking dropped the session from memory (Session#suspend); + // its tokens are read again now that the key is open. + Session.restore(() -> { + DeviceKey.revokeOrphan(); + if (previous != null) { + Ui.reshow(previous); + } else { + home(); + } + }, Bastion::signInScreen, Bastion::signInScreen); + } + }); + if (Session.hasStoredSession() && Keys.exists() && Keys.protectedByUser()) { + // The stored session is sealed under the app key: the biometric + // or PIN that opens the key comes first (LockForm), then the + // session is read. + Lock.lockAtStart(); + } else if (Session.hasStoredSession() && Keys.openPlain()) { + // No biometric or PIN set up: the session opens behind the lock, + // whose only way out is the password (LockForm). + Session.restore(Lock::lock, Bastion::signInScreen, Bastion::signInScreen); + } else { + signInScreen(); + } + } + + @Override + public void start() { + super.start(); + Lock.resumed(); + Protect.resumed(); + } + + @Override + public void stop() { + super.stop(); + Lock.paused(); + } + + /// Signed out by the user (the menu, or "Sign in again" on the lock): + /// every key on this device goes with the session -- the app key, its + /// biometric and PIN copies and the payment-signing key -- so whoever + /// signs in next starts from nothing (`Keys.wipe`). + static void signedOut() { + Keys.wipe().ready(done -> { + Lock.reset(); + signInScreen(); + }); + } + + static void signInAgain() { + Lock.reset(); + signInScreen(); + } + + static void signInScreen() { + // A key the user did not open is given up here, before the sign-in + // stores new tokens under whatever key is current (Keys#signedIn). + Keys.signedIn(); + SignInForm.show(() -> { + // A fresh sign-in is the strongest unlock there is; whatever was + // on screen before belonged to the session that ended. + Lock.reset(); + if (Keys.claim(Session.username())) { + // The key was another account's: the tokens just stored were + // sealed under it and are sealed again under the new one. + Session.resealTokens(); + } + DeviceKey.revokeOrphan(); + home(); + }); + } + + static void home() { + Nav.home(); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/Prefs.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/Prefs.java new file mode 100644 index 00000000000..c493c73fe82 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/Prefs.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion; + +import com.codename1.io.Preferences; + +/// The few choices the user makes on this device. Nothing here is a secret: +/// a choice is a preference, and every secret is in secure storage. +public final class Prefs { + private static final String ONBOARDED = "bastion.onboarded."; + + private Prefs() { + } + + /// Whether `user` has been through onboarding on this device; the steps + /// they skipped are offered again until then. + public static boolean onboarded(String user) { + return user != null && Preferences.get(ONBOARDED + user, false); + } + + public static void setOnboarded(String user) { + if (user != null) { + Preferences.set(ONBOARDED + user, true); + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/money/Amounts.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/money/Amounts.java new file mode 100644 index 00000000000..29c2a7a93fb --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/money/Amounts.java @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +import com.codename1.l10n.L10NManager; + +/// Amounts as text and back, in **integer arithmetic only**. +/// +/// A balance is a whole number of the asset's smallest unit (see [Assets]). +/// Turning one into a `double` to print it is how a bank app shows +/// `0.30000000000000004` or, worse, posts one cent less than the user typed: +/// a double has 53 bits, and 0.1 is not one of the numbers it can hold. So the +/// text is cut out of the digits of the `long` itself, and what the user types +/// is read digit by digit into a `long`, refused rather than rounded when it +/// has more decimal places than the asset has. +/// +/// The separators are the device locale's ([#useLocale]): `1,234.56` in +/// English is `1.234,56` in German and `1 234,56` in French. Reading `10,50` +/// with the English rules would skip the comma as a thousands separator and +/// take ten euros fifty for one thousand and fifty -- so a grouping separator +/// is accepted only where a group of three digits follows it, and anything +/// else is refused rather than guessed at. +public final class Amounts { + /// What [#parse] answers for text that is not an amount. + public static final long INVALID = -1; + + private static char decimal = '.'; + private static char grouping = ','; + + private Amounts() { + } + + /// Takes the separators from the device locale, as `L10NManager` formats + /// a number; keeps the English ones when it cannot tell. + public static void useLocale() { + String sample; + try { + sample = L10NManager.getInstance().format(1234567.5, 1); + } catch (RuntimeException noLocale) { + return; + } + // "1,234,567.5": the last non-digit is the decimal separator, the + // first one the grouping separator (none when the locale has none). + int first = -1; + int last = -1; + for (int iter = 0; sample != null && iter < sample.length(); iter++) { + char c = sample.charAt(iter); + if (c < '0' || c > '9') { + if (first < 0) { + first = iter; + } + last = iter; + } + } + if (last < 0) { + return; + } + char dec = sample.charAt(last); + char group = first == last ? 0 : sample.charAt(first); + if (group == dec) { + return; + } + useSeparators(dec, group); + } + + /// Uses `decimalSeparator` and `groupingSeparator` (0 for none). + public static void useSeparators(char decimalSeparator, char groupingSeparator) { + decimal = decimalSeparator; + grouping = groupingSeparator; + } + + public static char decimalSeparator() { + return decimal; + } + + /// `units` with `decimals` places and the locale's separators: + /// `format(123456, 2)` is `1,234.56` in English. Crypto shows every place it has + /// down to `minFraction`, trailing zeros dropped: `0.015 BTC`, not + /// `0.01500000`. + public static String format(long units, int decimals, int minFraction) { + boolean negative = units < 0; + // Long.MIN_VALUE has no positive twin; it is never a balance, but the + // digits are taken from the negative side so it still prints. + String digits = negative ? Long.toString(units).substring(1) : Long.toString(units); + while (digits.length() <= decimals) { + digits = "0" + digits; + } + String whole = digits.substring(0, digits.length() - decimals); + String fraction = digits.substring(digits.length() - decimals); + int keep = fraction.length(); + while (keep > minFraction && fraction.charAt(keep - 1) == '0') { + keep--; + } + fraction = fraction.substring(0, keep); + StringBuilder out = new StringBuilder(); + if (negative) { + out.append('-'); + } + for (int iter = 0; iter < whole.length(); iter++) { + if (iter > 0 && grouping != 0 && (whole.length() - iter) % 3 == 0) { + out.append(grouping); + } + out.append(whole.charAt(iter)); + } + if (fraction.length() > 0) { + out.append(decimal).append(fraction); + } + return out.toString(); + } + + /// `units` of `asset` the way the app shows money: `$1,234.56`, + /// `1,234.56 EUR`, `0.015 BTC`. + public static String format(long units, String asset) { + int decimals = Assets.decimals(asset); + boolean crypto = Assets.isCrypto(asset); + String number = format(units, decimals, crypto ? Math.min(2, decimals) : decimals); + if ("USD".equals(asset)) { + return number.startsWith("-") ? "-$" + number.substring(1) : "$" + number; + } + return number + " " + asset; + } + + /// The number alone, as an input field shows it: no grouping, the + /// locale's decimal separator. + public static String plain(long units, String asset) { + int decimals = Assets.decimals(asset); + char was = grouping; + grouping = 0; + try { + return format(units, decimals, Assets.isCrypto(asset) ? 0 : decimals); + } finally { + grouping = was; + } + } + + /// What the user typed, in units of an asset with `decimals` places, or + /// [#INVALID]. In English, accepts `12`, `12.5`, `1,200.50`, `.5`; + /// refuses a sign, a second decimal separator, a grouping separator not + /// followed by exactly three digits (`10,50`, `1,2,3`) or after the + /// decimal separator, more places than the asset has, and anything that + /// would not fit in a `long`. Zero is a valid amount here; whether it is + /// a valid payment is the caller's question. + public static long parse(String text, int decimals) { + if (text == null) { + return INVALID; + } + String trimmed = text.trim(); + if (trimmed.length() == 0) { + return INVALID; + } + long value = 0; + int places = -1; + boolean anyDigit = false; + // Digits since the last grouping separator, and whether one was seen: + // every group after the first must be exactly three digits. + int run = 0; + boolean grouped = false; + for (int iter = 0; iter < trimmed.length(); iter++) { + char c = trimmed.charAt(iter); + if (isGrouping(c)) { + if (places >= 0 || run == 0 || (grouped ? run != 3 : run > 3)) { + return INVALID; + } + grouped = true; + run = 0; + continue; + } + if (c == decimal) { + if (places >= 0 || (grouped && run != 3)) { + return INVALID; + } + places = 0; + continue; + } + if (c < '0' || c > '9') { + return INVALID; + } + if (places >= 0) { + places++; + if (places > decimals) { + return INVALID; + } + } else { + run++; + } + anyDigit = true; + value = times10plus(value, c - '0'); + if (value < 0) { + return INVALID; + } + } + if (!anyDigit || (grouped && places < 0 && run != 3)) { + return INVALID; + } + for (int pad = Math.max(places, 0); pad < decimals; pad++) { + value = times10plus(value, 0); + if (value < 0) { + return INVALID; + } + } + return value; + } + + /// Whether `c` is the grouping separator. A locale that groups with a + /// space (French: a narrow no-break space) is typed with any of the + /// three spaces. + private static boolean isGrouping(char c) { + if (grouping == 0) { + return false; + } + if (isSpace(grouping)) { + return isSpace(c); + } + return c == grouping; + } + + private static boolean isSpace(char c) { + return c == ' ' || c == '\u00a0' || c == '\u202f'; + } + + /// [#parse] for an amount of `asset`. + public static long parse(String text, String asset) { + return parse(text, Assets.decimals(asset)); + } + + /// What `quantity` units of an asset with `baseDecimals` places is worth + /// at `price` (quote units per one whole base unit), rounded down -- the + /// server's rule for a valuation that is shown, not charged. Split into + /// whole and part so that a large holding at a large price cannot + /// overflow on the way: `q * p / 10^d` would, `(q / 10^d) * p` does not. + public static long value(long quantity, int baseDecimals, long price) { + long scale = pow10(baseDecimals); + long whole = quantity / scale; + long part = quantity % scale; + return whole * price + part * price / scale; + } + + /// 10 to the `n`, for the decimals an asset can have. + public static long pow10(int n) { + long out = 1; + for (int iter = 0; iter < n; iter++) { + out *= 10; + } + return out; + } + + /// `value * 10 + digit`, or -1 when it would overflow. + private static long times10plus(long value, int digit) { + if (value > (Long.MAX_VALUE - digit) / 10) { + return -1; + } + return value * 10 + digit; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/money/Assets.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/money/Assets.java new file mode 100644 index 00000000000..053bb463c8d --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/money/Assets.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +import com.codenameone.examples.bastion.api.AssetDto; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; + +import java.util.List; + +/// The assets the bank holds, and how many decimal places each is counted in. +/// +/// The ledger keeps every amount as a whole number of the asset's smallest +/// unit -- cents for dollars, satoshi for bitcoin -- and `decimals` says how +/// many of those make one: an `AccountDto.balance` of `150` in USD is $1.50. +/// The server's `asset` table is the source, read once per run through +/// `GET /api/assets` ([#load]); until then, and for an asset it does not +/// list, amounts are shown in raw units rather than guessed at. +public final class Assets { + public static final String FIAT = "FIAT"; + public static final String CRYPTO = "CRYPTO"; + + /// The currency balances are totalled in on the dashboard. + public static final String REPORTING = "USD"; + + private static Asset[] all = new Asset[0]; + private static boolean loaded; + + /// One asset. + public static final class Asset { + public final String code; + public final String name; + public final String kind; + public final int decimals; + + Asset(String code, String name, String kind, int decimals) { + this.code = code; + this.name = name; + this.kind = kind; + this.decimals = decimals; + } + + public boolean isCrypto() { + return CRYPTO.equals(kind); + } + } + + private Assets() { + } + + /// Reads the list from the server once, then runs `then` (also when the + /// call failed: the screens still work, in raw units, and the next call + /// tries again). + public static void load(final Runnable then) { + if (loaded) { + then.run(); + return; + } + Api.banking().assets(Net.to((List list) -> { + replace(list); + then.run(); + }, (status, message) -> then.run())); + } + + /// Replaces the table with the server's answer, fiat first. + static void replace(List list) { + Asset[] out = new Asset[list.size()]; + int at = 0; + for (int pass = 0; pass < 2; pass++) { + for (AssetDto dto : list) { + if (CRYPTO.equals(dto.kind()) == (pass == 1)) { + out[at++] = new Asset(dto.code(), dto.name(), dto.kind(), dto.decimals()); + } + } + } + all = out; + loaded = true; + } + + /// The asset named `code`, or null. + public static Asset get(String code) { + for (Asset asset : all) { + if (asset.code.equals(code)) { + return asset; + } + } + return null; + } + + /// The decimals of `code`; 0 for an asset this app does not know, so its + /// amounts are shown as the raw units they are. + public static int decimals(String code) { + Asset asset = get(code); + return asset == null ? 0 : asset.decimals; + } + + public static boolean isCrypto(String code) { + Asset asset = get(code); + return asset != null && asset.isCrypto(); + } + + /// Every asset, fiat first. + public static Asset[] all() { + Asset[] copy = new Asset[all.length]; + System.arraycopy(all, 0, copy, 0, all.length); + return copy; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Api.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Api.java new file mode 100644 index 00000000000..0314b64733d --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Api.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.net; + +import com.codename1.io.rest.RestClients; +import com.codenameone.examples.bastion.api.AccountApi; +import com.codenameone.examples.bastion.api.AdminApi; +import com.codenameone.examples.bastion.api.AuditApi; +import com.codenameone.examples.bastion.api.BooksApi; +import com.codenameone.examples.bastion.api.InsightsApi; +import com.codenameone.examples.bastion.api.BankingApi; +import com.codenameone.examples.bastion.api.ExchangeApi; + +/// The typed clients, generated at build time from the contracts in the +/// `shared` module. Nothing else in the app builds an API URL. +public final class Api { + private static AccountApi account; + private static BankingApi banking; + private static ExchangeApi exchange; + private static AdminApi admin; + private static BooksApi books; + private static AuditApi audit; + private static InsightsApi insights; + + private Api() { + } + + static void connect(String baseUrl) { + account = RestClients.create(AccountApi.class, baseUrl); + banking = RestClients.create(BankingApi.class, baseUrl); + exchange = RestClients.create(ExchangeApi.class, baseUrl); + admin = RestClients.create(AdminApi.class, baseUrl); + books = RestClients.create(BooksApi.class, baseUrl); + audit = RestClients.create(AuditApi.class, baseUrl); + insights = RestClients.create(InsightsApi.class, baseUrl); + } + + public static AccountApi account() { + return account; + } + + public static BankingApi banking() { + return banking; + } + + public static ExchangeApi exchange() { + return exchange; + } + + public static AdminApi admin() { + return admin; + } + + /// The bank's own books; staff only (admin or auditor). + public static BooksApi books() { + return books; + } + + /// The audit log, read by staff; staff only. + public static AuditApi audit() { + return audit; + } + + /// Charts of a customer's own money, and market candles. + public static InsightsApi insights() { + return insights; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Guard.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Guard.java new file mode 100644 index 00000000000..d3b3f4b79fb --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Guard.java @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.net; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.NetworkGuard; +import com.codename1.io.NetworkManager; +import com.codename1.io.Log; +import com.codename1.security.shield.AppShield; +import com.codename1.security.shield.HostPolicy; +import com.codename1.security.shield.PinSet; +import com.codename1.security.shield.ShieldConfig; +import com.codenameone.examples.bastion.AppConfig; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +/// The one gate every request of the app goes through, installed before the +/// first request is made. +/// +/// - **No cleartext.** A plain `http` request is refused before a connection +/// is opened, unless it is to the development server on this machine +/// ([AppConfig#isDevelopmentServer]). +/// - **A host allow-list.** A request to a host not in +/// [AppConfig#ALLOWED_HOSTS] is refused, so nothing -- a redirect, a link in +/// a message, a server typed in the settings -- can take the bearer token or +/// a signed request somewhere else. +/// - **Certificate pinning.** A host in [AppConfig#PINS] must present a chain +/// with one of its pinned keys, or the request fails before its body is +/// written. +/// - **AppShield.** The attestation token and the pins the attestation service +/// publishes over the air, by delegating to the shield's own guard. Without +/// the enterprise engine (any local build) the shield is inert and says so. +/// +/// `NetworkManager` takes one guard and seals the slot, which is why this is +/// installed before `AppShield.init`: the shield then finds the slot taken, +/// and this guard calls the shield's from its own methods instead. +public final class Guard implements NetworkGuard { + private static Guard installed; + private final PinSet pins = new PinSet(AppConfig.pinTable(), 1, 0, 0); + // Counted on the network thread, read on the EDT. + private final AtomicInteger refused = new AtomicInteger(); + private final AtomicInteger pinFailures = new AtomicInteger(); + + Guard() { + } + + /// Installs the guard and starts the shield. Called once, before the first + /// request. Answers false when the guard could not be installed -- the + /// slot was taken by something else -- and the app must then not make a + /// single request: it fails closed (`Bastion.runApp` shows a fatal + /// screen) rather than run unguarded. + public static boolean install() { + if (installed != null) { + return true; + } + Guard guard = new Guard(); + try { + NetworkManager.setNetworkGuard(guard); + } catch (RuntimeException alreadyInstalled) { + Log.p("Bastion: a network guard was already installed; refusing to run unguarded"); + return false; + } + installed = guard; + ShieldConfig shield = new ShieldConfig() + // Every API host carries the attestation token and fails closed + // when there is a pin set and it does not match. + .collectSignals(true); + for (String host : AppConfig.ALLOWED_HOSTS) { + shield.protect(host, HostPolicy.ENFORCED); + } + AppShield.init(shield); + return true; + } + + public static Guard get() { + return installed; + } + + /// Why `url` may not be requested, or null when it may. + public static String refusal(String url) { + if (AppConfig.isDevelopmentServer(url)) { + return null; + } + String host = AppConfig.hostOf(url); + if (host == null) { + return "not an http(s) address"; + } + if (!AppConfig.isHttps(url)) { + return "cleartext http to " + host; + } + if (!AppConfig.isAllowedHost(host)) { + return host + " is not on the allow-list"; + } + return null; + } + + /// The pins this build carries. + public PinSet pins() { + return pins; + } + + /// How many requests this guard refused since start: shown in the + /// Security Center. + public int refusedCount() { + return refused.get(); + } + + public int pinFailureCount() { + return pinFailures.get(); + } + + private void count(boolean pin) { + (pin ? pinFailures : refused).incrementAndGet(); + } + + @Override + public void beforeRequest(ConnectionRequest request) throws IOException { + String why = refusal(request.getUrl()); + if (why != null) { + count(false); + throw new IOException("Refused by the network policy: " + why); + } + NetworkGuard shield = AppShield.getNetworkGuard(); + if (shield != null) { + shield.beforeRequest(request); + } + } + + @Override + public boolean isCertificateCheckRequired(String url) { + String host = AppConfig.hostOf(url); + if (host != null && pins.isEnforcedFor(host)) { + return true; + } + NetworkGuard shield = AppShield.getNetworkGuard(); + return shield != null && shield.isCertificateCheckRequired(url); + } + + @Override + public void checkCertificates(ConnectionRequest request, + ConnectionRequest.SSLCertificate[] certificates) throws IOException { + String host = AppConfig.hostOf(request.getUrl()); + if (host != null && pins.isEnforcedFor(host)) { + if (certificates == null || certificates.length == 0) { + // No chain to check -- a platform may hand none for a + // connection resumed from its TLS session cache. A pinned host + // is not let through unchecked on that account: the request + // fails closed, like a mismatch. Whether a retry then gets a + // fresh handshake is the platform's choice, so this can cost a + // failed request until the cached session ends; that is the + // price of the pin meaning something. (AppShield's own guard + // treats an empty chain as "not seen", so that its over-the- + // air pins can never brick an app; for the hosts this app + // pins itself, an unchecked request is the worse failure.) + count(true); + throw new IOException("No certificate chain to check for " + host); + } + String[] spki = new String[certificates.length]; + for (int iter = 0; iter < spki.length; iter++) { + spki[iter] = certificates[iter].getPublicKeyDigest(); + } + if (!pins.matches(host, spki)) { + count(true); + throw new IOException("The certificate of " + host + + " matched none of its pins"); + } + } + NetworkGuard shield = AppShield.getNetworkGuard(); + if (shield != null) { + shield.checkCertificates(request, certificates); + } + } + + @Override + public String[] interestingResponseHeaders() { + NetworkGuard shield = AppShield.getNetworkGuard(); + return shield == null ? new String[0] : shield.interestingResponseHeaders(); + } + + @Override + public void afterResponse(ConnectionRequest request, int responseCode, String[] headers) { + NetworkGuard shield = AppShield.getNetworkGuard(); + if (shield != null) { + shield.afterResponse(request, responseCode, headers); + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Net.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Net.java new file mode 100644 index 00000000000..b234adb85cc --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Net.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.net; + +import com.codename1.ui.plaf.UIManager; +import com.codenameone.examples.bastion.api.Refusal; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; +import com.codenameone.examples.bastion.ui.Lang; + +/// Makes a call through a generated client and sorts out what came back. +/// +/// A generated method answers with a `Response` whatever happened: null when +/// the server could not be reached, and otherwise a status with either the +/// typed result or, for a refusal, the server's reason as text. This turns that +/// into one of two callbacks, so a screen writes what to do with the result and +/// what to do with a failure, and nothing else. +/// +/// ```java +/// Api.orders().order(id, Net.to(order -> show(order), Ui::fail)); +/// ``` +public final class Net { + private static String lastCode; + + /// A call to a generated client method, waiting for its callback. + public interface Call { + void run(OnComplete> callback); + } + + public interface Ok { + void got(T value); + } + + public interface Failed { + /// @param status the HTTP status, or 0 when the server was not reached + /// @param message something to show the user + void failed(int status, String message); + } + + private Net() { + } + + /// The callback to hand a generated method: `ok` gets the result, `failed` + /// everything else. + /// + /// Both are dropped when the session epoch moved on while the call was + /// out (the app locked, or the user signed out or in again): the answer + /// belongs to a session that is no longer on screen. + public static OnComplete> to(final Ok ok, final Failed failed) { + final int epoch = Session.epoch(); + return new OnComplete>() { + @Override + public void completed(Response response) { + if (epoch != Session.epoch()) { + return; + } + if (response == null) { + lastCode = null; + failed.failed(0, Lang.tr("Could not reach the server. Check your connection.")); + return; + } + int status = response.getResponseCode(); + if (status >= 200 && status < 300) { + ok.got(response.getResponseData()); + return; + } + failed.failed(status, reason(response, status)); + } + }; + } + + /// Makes a call that was handed over as a value, which is how one screen + /// serves several calls of the same shape. + public static void send(Call call, Ok ok, Failed failed) { + call.run(to(ok, failed)); + } + + /// What the server said when it refused. Read as an `Object` and tested, + /// never cast to the type the call was declared with: on a refusal the body + /// is text whatever that type is. + private static String reason(Response response, int status) { + lastCode = null; + Object body = response.getResponseData(); + if (body instanceof String && ((String) body).length() > 0 + && ((String) body).length() < 300 && ((String) body).indexOf('<') < 0 + && ((String) body).indexOf('{') < 0) { + lastCode = Refusal.code((String) body); + return localize((String) body); + } + if (status == 401) { + return Lang.tr("Please sign in again."); + } + if (status == 403) { + return Lang.tr("Your account is not allowed to do that."); + } + if (status == 429) { + return Lang.tr("Too many attempts. Wait a moment and try again."); + } + return Lang.tr("Something went wrong ({0}). Try again.", String.valueOf(status)); + } + + /// A refusal in the user's language: the server sends a stable code with + /// its English sentence ([Refusal]), the bundle has the code as + /// `error.`. A code this app does not know yet (a newer server) + /// is shown in the server's English rather than not at all. + public static String localize(String body) { + String code = Refusal.code(body); + if (code == null) { + return body; + } + String key = "error." + code; + String pattern = UIManager.getInstance().localize(key, null); + if (pattern == null || pattern.length() == 0 || pattern.equals(key)) { + return Refusal.english(body); + } + return Refusal.fill(pattern, Refusal.values(body)); + } + + /// The stable code of the refusal being reported, or null: read inside a + /// [Failed] callback, which runs on the EDT right after it is set, to + /// tell one refusal from another without comparing translated text. + public static String refusalCode() { + return lastCode; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Session.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Session.java new file mode 100644 index 00000000000..8019cf4f9a5 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Session.java @@ -0,0 +1,839 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.net; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; +import com.codename1.io.Preferences; +import com.codename1.io.RequestAuthorizer; +import com.codename1.io.Util; +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcConfiguration; +import com.codename1.io.oidc.OidcRequestAuthorizer; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.oidc.PkceChallenge; +import com.codename1.io.oidc.SecureStorageTokenStore; +import com.codename1.security.SecureStorage; +import com.codenameone.examples.bastion.security.SealedStorage; +import com.codename1.io.oidc.TokenStore; +import com.codename1.io.webauthn.PublicKeyCredential; +import com.codename1.io.webauthn.PublicKeyCredentialCreationOptions; +import com.codename1.io.webauthn.PublicKeyCredentialRequestOptions; +import com.codename1.io.webauthn.WebAuthnClient; +import com.codename1.ui.CN; +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; +import com.codenameone.examples.bastion.AppConfig; +import com.codenameone.examples.bastion.api.UserDto; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Map; + +/// Who is signed in, and the tokens that say so. +/// +/// Signing in is OAuth's authorization-code flow with PKCE against the bank's +/// own server, made without a browser, as in the Savor sample: the app posts +/// the password for a short-lived session, exchanges the session for an +/// authorization code and the code for tokens. What Bastion adds: +/// +/// - **A second factor.** An account with an authenticator app answers the +/// password with a redirect to `/login/mfa`: the session is held back until +/// the six-digit code is posted to it ([#signIn] answers a [Pending]). +/// - **Passkeys.** [#signInWithPasskey] runs the WebAuthn ceremony through +/// the platform's passkey UI ([WebAuthnClient]); a passkey whose +/// authenticator verified the user is both factors at once. +/// - **Short tokens.** The server issues five-minute access tokens and a +/// rotating refresh token; [OidcRequestAuthorizer] renews them, and keeps +/// the pair in the platform's secure storage (Keychain / Keystore), never in +/// plain preferences outside the simulator. +/// - **The same flow in a browser.** There the app sees neither the session +/// cookie nor a redirect: the browser keeps the first in its own jar and +/// follows the second. So in a browser every step is read from its answer +/// instead -- the server's JSON for the password, the code and the passkey, +/// and the authorization code from the page the redirect ends on +/// ([AppConfig#redirectUri]) -- and no `Cookie` header is ever written. +public final class Session { + private static final String STORE_PREFIX = "bastion"; + private static final String SESSION_COOKIE = "CN1SESSION"; + + private static String base; + private static OidcClient client; + private static OidcRequestAuthorizer authorizer; + private static TokenStore store; + private static UserDto user; + private static Runnable onSignedOut; + /// Moved on whenever the server changes or an account signs in or out: + /// an answer from an earlier session is dropped, never installed. + private static int session; + /// A signed-in server session kept for one passkey ceremony. + private static boolean keepForPasskey; + private static String ceremonyServer; + private static String ceremonyCookie; + + private Session() { + } + + /// A password the server accepted, waiting for its second factor. + public static final class Pending { + final String server; + final String cookie; + + Pending(String server, String cookie) { + this.server = server; + this.cookie = cookie; + } + } + + /// Points the app at a server. + public static void start(String baseUrl) { + if (store == null) { + // Sealed under the app key (Keys), which a biometric or the PIN + // opens: the refresh token is behind the same lock as the payment + // key. Plain storage only in the simulator, which has no keystore. + // On a device without one the tokens are not kept at all: the + // user signs in at every start, which is the right failure for a + // bank. + store = new Marked(new SecureStorageTokenStore( + new SealedStorage(SecureStorage.getInstance())) + .allowPlainStorageFallback(CN.isSimulator())); + } + if (base != null) { + NetworkManager.getInstance().setAuthorizer(base + "/api", null); + if (!base.equals(baseUrl)) { + store.clear(storeKey(base)); + } + } + base = baseUrl; + user = null; + session++; + client = OidcClient.create(OidcConfiguration.newBuilder() + .issuer(base) + .authorizationEndpoint(base + "/oauth2/authorize") + .tokenEndpoint(base + "/oauth2/token") + .revocationEndpoint(base + "/oauth2/revoke") + .jwksUri(base + "/oauth2/jwks") + .build()) + .setClientId(AppConfig.CLIENT_ID) + .setRedirectUri(AppConfig.redirectUri()) + .setScopes("openid", "profile") + .setTokenStore(store) + .setStoreKey(storeKey(base)); + authorizer = new OidcRequestAuthorizer(client); + authorizer.install(base + "/api"); + authorizer.addSignInRequiredListener((source, reason) -> { + user = null; + session++; + if (onSignedOut != null) { + onSignedOut.run(); + } + }); + Api.connect(base); + } + + /// The name tokens from one server are kept under: one name per server, + /// so a refresh token is never sent to a server that did not issue it. + static String storeKey(String server) { + StringBuilder key = new StringBuilder(STORE_PREFIX).append('-'); + for (int iter = 0; iter < server.length(); iter++) { + char c = server.charAt(iter); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + key.append(c); + } else { + key.append('_').append(Integer.toHexString(c)).append('_'); + } + } + return key.toString(); + } + + /// Says who is signed in without asking the server (the tests). + public static void useUser(UserDto signedIn) { + user = signedIn; + } + + /// Keeps the tokens somewhere else (the tests keep them in memory). + public static void useStore(TokenStore tokens) { + store = tokens == null ? null : new Marked(tokens); + } + + private static final String HELD = "bastion.session."; + + /// Notes, in the preferences, which keys hold a saved session, so the + /// start can tell "signed in, behind the lock" from "never signed in" + /// before the app key is open -- the tokens themselves cannot be read + /// until then. Kept here, under the key the session itself uses: the + /// account name the token store writes under is its own business, and + /// guessing it is how every reload used to land on the sign-in form. + static final class Marked implements TokenStore { + private final TokenStore inner; + + Marked(TokenStore inner) { + this.inner = inner; + } + + @Override + public AsyncResource load(final String key) { + AsyncResource loaded = inner.load(key); + loaded.ready(tokens -> { + // Read with the key open and nothing there: the marker is out + // of date (the store was wiped, or the sealing key replaced), + // and keeping it would send every start to a lock that guards + // nothing and then on to the sign-in form. + if (tokens == null || tokens.getAccessToken() == null) { + Preferences.delete(HELD + key); + } + }); + return loaded; + } + + @Override + public AsyncResource save(final String key, OidcTokens tokens) { + AsyncResource saved = inner.save(key, tokens); + saved.ready(ok -> { + if (ok != null && ok.booleanValue()) { + Preferences.set(HELD + key, true); + } + }); + return saved; + } + + @Override + public AsyncResource clear(String key) { + Preferences.delete(HELD + key); + return inner.clear(key); + } + } + + public static void onSignedOut(Runnable handler) { + onSignedOut = handler; + } + + public static String serverUrl() { + return base; + } + + public static UserDto user() { + return user; + } + + /// The session epoch: moves on at every sign-in, sign-out, server change + /// and lock. A callback made in one epoch is dropped in another + /// ([Net#to]), so an answer that arrives after the lock never paints an + /// account screen. + public static int epoch() { + return session; + } + + /// The app locked: the tokens are dropped from memory (not from the + /// sealed store), which also abandons a renewal in progress, and the + /// epoch moves on. [#restore] picks them up again after the unlock. + /// + /// A renewal that had already reached the server when the lock came is + /// lost with its answer; the unlock then finds the stored refresh token + /// spent and asks for a sign-in -- the safe failure, and a narrow one. + public static void suspend() { + endPasskeySession(); + user = null; + session++; + if (authorizer != null) { + authorizer.setTokens(null); + } + } + + /// Seals the tokens in use again: after `Keys.claim` replaced the key + /// they were sealed under. + public static void resealTokens() { + OidcTokens tokens = authorizer == null ? null : authorizer.getTokens(); + if (tokens != null && base != null) { + store.save(storeKey(base), tokens); + } + } + + /// The signed-in account's name, or null. + public static String username() { + return user == null ? null : user.username(); + } + + /// Whether this device keeps a sealed session for the current server, + /// readable once the app key is open. + public static boolean hasStoredSession() { + return base != null && hasStoredSession(base); + } + + static boolean hasStoredSession(String server) { + return Preferences.get(HELD + storeKey(server), false); + } + + /// Whether a refresh token is held: what the lock screen protects. + public static boolean hasTokens() { + OidcTokens tokens = authorizer == null ? null : authorizer.getTokens(); + return tokens != null && tokens.getAccessToken() != null; + } + + /// Picks up the session a previous run left, if it is still good. + public static void restore(final Runnable signedIn, final Runnable signedOut, + final Runnable unreachable) { + final int asked = session; + authorizer.load().ready(new SuccessCallback() { + @Override + public void onSucess(OidcTokens tokens) { + if (asked != session) { + return; + } + if (tokens == null || tokens.getAccessToken() == null) { + signedOut.run(); + return; + } + refreshUser(signedIn, (status, message) -> { + if (status == 0) { + unreachable.run(); + } else { + signedOut.run(); + } + }); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + if (asked == session) { + signedOut.run(); + } + } + }); + } + + public static void refreshUser(final Runnable done, final Net.Failed failed) { + final int asked = session; + Api.account().me(Net.to(described -> { + if (asked != session) { + return; + } + user = described; + done.run(); + }, (status, message) -> { + if (asked == session) { + failed.failed(status, message); + } + })); + } + + /// An e-mail address the way the server names its account: trimmed, ASCII + /// letters folded by hand (`toLowerCase()` follows the device locale). + public static String accountName(String email) { + if (email == null) { + return null; + } + return AppConfig.asciiLower(email.trim()); + } + + /// What a password sign-in to an account with a passkey is answered with. + static final String PASSKEY_REQUIRED = "This account signs in with its passkey. " + + "Use the passkey, or ask the bank to help you recover the account."; + + /// Signs in with a password. An account with an authenticator app is + /// answered with `needsCode`, and [#submitCode] finishes it. + public static void signIn(String email, String password, final Net.Ok needsCode, + final Runnable done, final Net.Failed failed) { + final String server = base; + final Step login = new Step("POST", server + "/login"); + login.addArgument("username", accountName(email)); + login.addArgument("password", password); + login.send(() -> { + if (!server.equals(base)) { + failed.failed(0, "The server changed. Sign in again."); + } else if (login.status == 0) { + failed.failed(0, "Could not reach the server. Check your connection."); + } else if (login.status == 429) { + failed.failed(429, "Too many attempts. Wait a minute and try again."); + } else if (login.status == 423) { + failed.failed(423, "This account is locked for a while after too many attempts."); + } else if (login.status == 403) { + // The account has a passkey and no recovery codes to stand in + // for it (SignInGuard on the server). + failed.failed(403, PASSKEY_REQUIRED); + } else if (web() ? login.status == 200 && !login.signedIn() + : login.session != null && login.status == 302 && login.location != null + && login.location.indexOf("/login/mfa") >= 0) { + // In a browser the redirect to /login/mfa was followed: the + // answer is that page, not the handler's `signedIn` JSON. + needsCode.got(new Pending(server, login.session)); + } else if (login.status == 200 && (web() ? login.signedIn() : login.session != null)) { + authorize(server, login.session, done, failed); + } else { + failed.failed(401, "That e-mail and password do not match an account."); + } + }); + } + + /// Posts the authenticator app's code (or a recovery code) for a password + /// that is waiting for it. + public static void submitCode(final Pending pending, String code, final Runnable done, + final Net.Failed failed) { + final Step mfa = new Step("POST", pending.server + "/login/mfa"); + mfa.cookie(pending.cookie); + mfa.addArgument("code", code == null ? "" : code.trim()); + mfa.send(() -> { + if (!pending.server.equals(base)) { + failed.failed(0, "The server changed. Sign in again."); + } else if (mfa.status == 0) { + failed.failed(0, "Could not reach the server. Check your connection."); + } else if (mfa.status == 429) { + failed.failed(429, "Too many attempts. Wait a minute and try again."); + } else if (mfa.status == 403) { + // A passkey account: the app's code is not enough, a recovery + // code is (SignInGuard#passkeyFirst on the server). + failed.failed(403, "This account signs in with its passkey. Without it, enter " + + "one of your recovery codes."); + } else if (mfa.status == 200 && (!web() || mfa.signedIn())) { + // A wrong code is a redirect back to the code page, which a + // browser follows to a 200 without `signedIn`. + // The session id changes once the user is fully signed in. + authorize(pending.server, mfa.session != null ? mfa.session : pending.cookie, + done, failed); + } else { + failed.failed(401, "That code is not right. Codes change every 30 seconds."); + } + }); + } + + /// Whether this platform can sign in with a passkey. + public static boolean passkeysSupported() { + return WebAuthnClient.isSupported(); + } + + /// Signs in with a passkey: the server's challenge, the platform's passkey + /// sheet, and the assertion back to the server. + public static void signInWithPasskey(final Runnable done, final Net.Failed failed) { + final String server = base; + final Step options = new Step("POST", server + "/webauthn/authenticate/options"); + options.json("{}"); + options.send(() -> { + if (!server.equals(base) || options.status != 200 + || (!web() && options.session == null)) { + failed.failed(options.status, "Passkey sign-in is not available right now."); + return; + } + PublicKeyCredentialRequestOptions request; + try { + request = PublicKeyCredentialRequestOptions.fromJson(options.text()); + } catch (RuntimeException malformed) { + failed.failed(options.status, "Passkey sign-in is not available right now."); + return; + } + WebAuthnClient.getInstance().get(request).ready(new SuccessCallback() { + @Override + public void onSucess(PublicKeyCredential credential) { + final Step login = new Step("POST", server + "/login/webauthn"); + login.cookie(options.session); + login.json(credential.toJson()); + login.send(() -> { + if (!server.equals(base)) { + failed.failed(0, "The server changed. Sign in again."); + } else if (login.status == 200 && "true".equals(login.answer("authenticated"))) { + authorize(server, login.session != null ? login.session + : options.session, done, failed); + } else { + failed.failed(401, "That passkey was not accepted."); + } + }); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + failed.failed(-1, "Passkey sign-in was cancelled."); + } + }); + }); + } + + /// The signed-in session, for an authorization code, for tokens. + private static void authorize(final String server, final String cookie, final Runnable done, + final Net.Failed failed) { + final int asked = session; + final PkceChallenge pkce = PkceChallenge.generate(); + final String state = PkceChallenge.generate().getVerifier(); + final Step authorize = new Step("GET", server + "/oauth2/authorize"); + authorize.cookie(cookie); + if (web()) { + // Or a session that did not take is answered with a page. + authorize.addRequestHeader("Accept", "application/json"); + } + authorize.addArgument("response_type", "code"); + authorize.addArgument("client_id", AppConfig.CLIENT_ID); + authorize.addArgument("redirect_uri", AppConfig.redirectUri()); + authorize.addArgument("scope", "openid profile"); + authorize.addArgument("state", state); + authorize.addArgument("code_challenge", pkce.getChallenge()); + authorize.addArgument("code_challenge_method", pkce.getMethod()); + authorize.send(() -> { + // In a browser the redirect was followed to SignInCodePage, which + // answers the code and state as JSON. + String code = web() ? authorize.answer("code") : parameter(authorize.location, "code"); + String returned = web() ? authorize.answer("state") + : parameter(authorize.location, "state"); + // A code that comes back with another state was not issued for + // this sign-in. + if (!server.equals(base) || code == null || !state.equals(returned)) { + failed.failed(authorize.status, "Signing in failed. Try again."); + return; + } + final Step token = new Step("POST", server + "/oauth2/token"); + token.addArgument("grant_type", "authorization_code"); + token.addArgument("code", code); + token.addArgument("redirect_uri", AppConfig.redirectUri()); + token.addArgument("code_verifier", pkce.getVerifier()); + token.addArgument("client_id", AppConfig.CLIENT_ID); + token.send(() -> { + OidcTokens tokens = token.status == 200 ? token.tokens() : null; + if (!server.equals(base) || asked != session || tokens == null + || tokens.getAccessToken() == null) { + failed.failed(token.status, "Signing in failed. Try again."); + return; + } + // The server session has done its work; it is ended rather + // than left to run out -- unless onboarding asked to keep it + // for one passkey ceremony, which only a session can run. + if (keepForPasskey) { + keepForPasskey = false; + ceremonyServer = server; + // In a browser the session is the browser's cookie; an + // empty marker says one is kept. + ceremonyCookie = cookie == null ? "" : cookie; + } else { + endServerSession(server, cookie); + } + session++; + authorizer.setTokens(tokens); + store.save(storeKey(base), tokens); + refreshUser(done, failed); + }); + }); + } + + /// Asks the next sign-in to keep its server session for one passkey + /// ceremony ([#addPasskey]). Passkeys are registered against the signed-in + /// session cookie, not the API's bearer token, so onboarding asks for this + /// before the sign-in that follows registration. + public static void keepSessionForPasskey() { + keepForPasskey = true; + } + + /// Whether [#addPasskey] can run now: a kept session, on a platform with + /// a passkey UI. + public static boolean canAddPasskey() { + return ceremonyCookie != null && base != null && base.equals(ceremonyServer) + && passkeysSupported(); + } + + /// Creates a passkey for the signed-in account: the server's creation + /// options, the platform's passkey sheet, and the new credential back to + /// the server under `label`. The kept session is ended either way. + public static void addPasskey(final String label, final Runnable done, + final Net.Failed failed) { + final String server = ceremonyServer; + final String cookie = ceremonyCookie; + if (!canAddPasskey()) { + failed.failed(0, "Sign in again to add a passkey."); + return; + } + final Step options = new Step("POST", server + "/webauthn/register/options"); + options.cookie(cookie); + options.json("{}"); + options.send(() -> { + PublicKeyCredentialCreationOptions create = null; + if (options.status == 200) { + try { + create = PublicKeyCredentialCreationOptions.fromJson(options.text()); + } catch (RuntimeException malformed) { + create = null; + } + } + if (create == null || !server.equals(base)) { + endPasskeySession(); + failed.failed(options.status, "Passkeys are not available right now."); + return; + } + WebAuthnClient.getInstance().create(create).ready(new SuccessCallback() { + @Override + public void onSucess(PublicKeyCredential credential) { + final Step register = new Step("POST", server + "/webauthn/register"); + register.cookie(cookie); + register.json("{\"publicKey\":{\"credential\":" + credential.toJson() + + ",\"label\":\"" + jsonText(label) + "\"}}"); + register.send(() -> { + endPasskeySession(); + if (register.status == 200 && "true".equals(register.answer("success"))) { + done.run(); + } else { + failed.failed(register.status, "That passkey was not saved."); + } + }); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + endPasskeySession(); + failed.failed(-1, "Creating the passkey was cancelled."); + } + }); + }); + } + + /// Ends a session kept for [#addPasskey] that was not used. + public static void endPasskeySession() { + keepForPasskey = false; + if (ceremonyCookie != null) { + endServerSession(ceremonyServer, ceremonyCookie); + } + ceremonyCookie = null; + ceremonyServer = null; + } + + private static void endServerSession(String server, String cookie) { + Step end = new Step("POST", server + "/logout"); + end.cookie(cookie); + end.send(() -> { }); + } + + /// How the current sign-in was made, as the server wrote it into the + /// access token's `amr` claim (`pwd`, `otp`, `hwk`, `mfa`). The token is + /// the server's word and is checked there; this reads it only to know + /// what the server will ask -- a passkey sign-in adds a device without + /// the password. + public static boolean signedInWith(String method) { + OidcTokens tokens = authorizer == null ? null : authorizer.getTokens(); + String token = tokens == null ? null : tokens.getAccessToken(); + int first = token == null ? -1 : token.indexOf('.'); + int second = first < 0 ? -1 : token.indexOf('.', first + 1); + if (second < 0) { + return false; + } + try { + byte[] payload = com.codename1.util.Base64.decodeUrlSafe( + token.substring(first + 1, second)); + if (payload == null) { + return false; + } + Map claims = new JSONParser().parseJSON(new InputStreamReader( + new ByteArrayInputStream(payload), "UTF-8")); + Object amr = claims == null ? null : claims.get("amr"); + return amr instanceof java.util.List && ((java.util.List) amr).contains(method); + } catch (IOException malformed) { + return false; + } catch (RuntimeException malformed) { + return false; + } + } + + /// Whether the app runs in a browser. + static boolean web() { + return AppConfig.pageOrigin() != null; + } + + /// `text` as the inside of a JSON string. + static String jsonText(String text) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < text.length(); iter++) { + char c = text.charAt(iter); + if (c == '"' || c == '\\') { + out.append('\\').append(c); + } else if (c < 0x20) { + out.append(' '); + } else { + out.append(c); + } + } + return out.toString(); + } + + /// Signs out: the refresh token is revoked at the server and the tokens + /// are wiped here, whether or not the server answers. + public static void signOut(final Runnable done) { + endPasskeySession(); + OidcTokens tokens = authorizer.getTokens(); + if (tokens != null && tokens.getRefreshToken() != null) { + client.revoke(tokens.getRefreshToken()); + } + user = null; + session++; + authorizer.signOut().ready(new SuccessCallback() { + @Override + public void onSucess(Boolean cleared) { + done.run(); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + done.run(); + } + }); + } + + /// A parameter of the query string of `url`, or null. + static String parameter(String url, String name) { + int query = url == null ? -1 : url.indexOf('?'); + if (query < 0) { + return null; + } + int at = query + 1; + while (at < url.length()) { + int end = url.indexOf('&', at); + if (end < 0) { + end = url.length(); + } + if (url.startsWith(name + "=", at)) { + return Util.decode(url.substring(at + name.length() + 1, end), "UTF-8", false); + } + at = end + 1; + } + return null; + } + + /// One request of the sign-in: no redirects followed, no cookie jar, no + /// bearer token -- the redirect and the cookie are the answers it is after. + private static final class Step extends ConnectionRequest { + int status; + String location; + String session; + private byte[] body; + private Runnable then; + private boolean reported; + + Step(String method, String url) { + setUrl(url); + setHttpMethod(method); + setPost(!"GET".equals(method)); + setFollowRedirects(false); + setCookiesEnabled(false); + setReadResponseForErrors(true); + setDuplicateSupported(true); + setFailSilently(true); + setAuthorizer(RequestAuthorizer.NONE); + } + + /// The server session to send: a `Cookie` header outside a browser. + /// In one the browser sends its own cookie and a page may not set + /// that header, so nothing is written. + void cookie(String value) { + if (!web() && value != null && value.length() > 0) { + addRequestHeader("Cookie", value); + } + } + + /// Whether the answer is the sign-in handlers' `{"signedIn":true}`. + boolean signedIn() { + return status == 200 && "true".equals(answer("signedIn")); + } + + void json(String text) { + setContentType("application/json"); + addRequestHeader("Accept", "application/json"); + setRequestBody(text); + } + + void send(Runnable next) { + then = next; + NetworkManager.getInstance().addToQueue(this); + } + + String text() { + try { + return body == null ? "" : new String(body, "UTF-8"); + } catch (IOException impossible) { + return ""; + } + } + + /// A member of the JSON this request was answered with, as text, or + /// null. + String answer(String name) { + Map json = parse(); + Object value = json == null ? null : json.get(name); + return value == null ? null : String.valueOf(value); + } + + OidcTokens tokens() { + Map json = parse(); + return json == null ? null : OidcTokens.fromTokenResponse(json, null); + } + + private Map parse() { + if (body == null) { + return null; + } + try { + return new JSONParser().parseJSON(new InputStreamReader( + new ByteArrayInputStream(body), "UTF-8")); + } catch (IOException malformed) { + return null; + } catch (RuntimeException malformed) { + return null; + } + } + + @Override + protected void readHeaders(Object connection) throws IOException { + location = getHeader(connection, "Location"); + String[] cookies = getHeaders(connection, "Set-Cookie"); + for (int iter = 0; cookies != null && iter < cookies.length; iter++) { + if (cookies[iter] != null && cookies[iter].startsWith(SESSION_COOKIE + "=")) { + int end = cookies[iter].indexOf(';'); + session = end < 0 ? cookies[iter] : cookies[iter].substring(0, end); + } + } + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + status = code; + } + + @Override + protected void readResponse(InputStream input) throws IOException { + if (status == 0) { + status = getResponseCode(); + } + body = Util.readInputStream(input); + } + + @Override + protected void postResponse() { + if (status == 0) { + status = getResponseCode(); + } + report(); + } + + @Override + protected void handleException(Exception err) { + status = 0; + report(); + } + + private void report() { + if (!reported) { + reported = true; + CN.callSerially(then); + } + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/DeviceKey.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/DeviceKey.java new file mode 100644 index 00000000000..a09a8bdab19 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/DeviceKey.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.security; + +import com.codename1.io.Preferences; +import com.codename1.security.DeviceIntegrity; +import com.codename1.security.KeyGenerator; +import com.codename1.security.KeyPair; +import com.codename1.security.PrivateKey; +import com.codename1.security.PublicKey; +import com.codename1.security.vault.AssociatedData; +import com.codename1.security.vault.SecureEnvelope; +import com.codename1.ui.CN; +import com.codename1.util.Base64; +import com.codename1.util.SuccessCallback; +import com.codenameone.examples.bastion.api.DeviceDto; +import com.codenameone.examples.bastion.api.DeviceRevokeDto; +import com.codenameone.examples.bastion.api.DeviceRegistrationDto; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.ui.Lang; + +/// This device's signing key: the second thing, after the session, that the +/// server asks for before money leaves an account. +/// +/// The key is an EC P-256 pair from [KeyGenerator#ec(int)], made by the +/// platform's own crypto provider; the server gets its X.509 public half and +/// the PKCS#8 private half never leaves the device. It is kept sealed: an +/// AES-256-GCM [SecureEnvelope] whose associated data names the account and +/// purpose (so a blob copied to another account's slot does not open), under +/// the app key ([Keys]), which the OS releases only after a biometric check +/// or which the user's PIN opens -- whichever they set up. The sealed blob +/// alone, in the app's preferences, is useless without one of those. +/// +/// In the simulator the OS store is an ordinary file, and the Security +/// Center says so. +public final class DeviceKey { + private static final String SEALED_KEY = "bastion.device.sealed"; + private static final String ID_KEY = "bastion.device.id"; + private static final String OWNER_KEY = "bastion.device.owner"; + /// A device the server registered whose key this device could not keep: + /// the app locked (or the session changed) while the registration was + /// on its way. It is revoked at the next chance ([#revokeOrphan]). + private static final String ORPHAN_KEY = "bastion.device.orphan"; + + private static PrivateKey cached; + + private DeviceKey() { + } + + /// The id the server gave this device, or null before registration. + public static String deviceId() { + String id = Preferences.get(ID_KEY, ""); + return id.length() == 0 ? null : id; + } + + /// The account the key was registered for, or null. + public static String owner() { + String owner = Preferences.get(OWNER_KEY, ""); + return owner.length() == 0 ? null : owner; + } + + /// Whether the key belongs to `username`: false for a key registered for + /// another account, or when nobody is signed in. + public static boolean ownedBy(String username) { + String owner = owner(); + return owner != null && owner.equals(username); + } + + /// The private key, unsealed, or null when there is none (or the wrapping + /// key is gone: a restored backup on a new phone, which is then simply a + /// device to register again). + public static PrivateKey privateKey() { + if (Lock.isLocked()) { + return null; + } + if (cached != null) { + return cached; + } + String sealed = Preferences.get(SEALED_KEY, ""); + String owner = owner(); + byte[] wrap = Keys.current(); + if (sealed.length() == 0 || owner == null || wrap == null) { + return null; + } + try { + cached = PrivateKey.fromPkcs8(PublicKey.EC, + SecureEnvelope.parse(Base64.decodeUrlSafe(sealed)).open(wrap, aad(owner))); + return cached; + } catch (RuntimeException tamperedOrForeign) { + // A blob that does not open under this device's key, or for this + // account, is not this device's key. + forget(); + return null; + } + } + + /// Drops the unsealed key from memory: the app lock calls this. + public static void dropFromMemory() { + cached = null; + } + + /// Forgets the key on this device; the server keeps its record until the + /// device is removed there (Security Center, or another device). + public static void forget() { + cached = null; + Preferences.delete(SEALED_KEY); + Preferences.delete(ID_KEY); + Preferences.delete(OWNER_KEY); + } + + /// Makes a new key and registers its public half for `owner`. `password` + /// is asked for when the account has no authenticator app (the server + /// wants a second proof that it is the owner adding a device); null + /// otherwise. The sign-in must be a few minutes old. + public static void register(final String owner, final String password, + final Net.Ok done, final Net.Failed failed) { + KeyPair pair = KeyGenerator.ec(256); + final PrivateKey key = pair.getPrivateKey(); + final String publicKey = Base64.encodeUrlSafe(pair.getPublicKey().getEncoded()); + final boolean rooted = DeviceIntegrity.isDeviceCompromised(); + if (DeviceIntegrity.isAttestationSupported()) { + // Play Integrity / App Attest over the public key, so the token is + // bound to this key and cannot be replayed for another. + DeviceIntegrity.requestIntegrityToken(publicKey).ready(new SuccessCallback() { + @Override + public void onSucess(String token) { + send(owner, password, key, publicKey, rooted, token, done, failed); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + send(owner, password, key, publicKey, rooted, null, done, failed); + } + }); + return; + } + send(owner, password, key, publicKey, rooted, null, done, failed); + } + + private static void send(final String owner, String password, final PrivateKey key, + String publicKey, boolean rooted, String token, final Net.Ok done, + Net.Failed failed) { + DeviceRegistrationDto request = new DeviceRegistrationDto(publicKey, + CN.getPlatformName(), rooted, token != null, token, password); + final int epoch = Session.epoch(); + // Not Net.to: that drops an answer from an earlier epoch, and this one + // must not be dropped -- the server has a device on record either way. + Api.banking().registerDevice(request, response -> { + DeviceDto device = response != null && response.getResponseCode() == 200 + ? response.getResponseData() : null; + if (device == null || epoch == Session.epoch()) { + Net.to((DeviceDto registered) -> keep(owner, key, registered, done, failed), + failed).completed(response); + return; + } + // Locked (or signed out) mid-registration: the key that goes with + // the server's record is gone from memory, and an untrusted + // stranger could not sign with it anyway. The record is revoked + // once there is a session again, so no device the user never saw + // finish stays on their account. + Preferences.set(ORPHAN_KEY, device.id()); + }); + } + + /// Keeps the key the server just registered as `device`: sealed under + /// the app key, for `owner`, who must still be the one signed in. + static void keep(String owner, PrivateKey key, DeviceDto device, + Net.Ok done, Net.Failed failed) { + byte[] wrap = Keys.current(); + if (wrap == null || !owner.equals(Session.username())) { + Preferences.set(ORPHAN_KEY, device.id()); + revokeOrphan(); + failed.failed(0, Lang.tr("Unlock Bastion first.")); + return; + } + byte[] sealed = SecureEnvelope.seal(wrap, "device-key", 1, aad(owner), key.getEncoded()); + Preferences.set(SEALED_KEY, Base64.encodeUrlSafe(sealed)); + Preferences.set(ID_KEY, device.id()); + Preferences.set(OWNER_KEY, owner); + cached = key; + done.got(device); + } + + /// The device left registered on the server by an interrupted + /// registration, or null. + public static String orphan() { + String id = Preferences.get(ORPHAN_KEY, ""); + return id.length() == 0 ? null : id; + } + + /// Revokes [#orphan] if there is one and a session to do it with; kept + /// for the next try when the server cannot be reached. Called after every + /// sign-in and unlock. + public static void revokeOrphan() { + final String id = orphan(); + if (id == null || Session.user() == null) { + return; + } + // Revoking is signed by this device's own key: the orphan's key was + // never kept, so there is nothing else to sign with. Until this device + // has a key of its own the orphan waits -- it is harmless meanwhile, + // since nobody holds its private key. + DeviceRevokeDto request = new DeviceRevokeDto(null); + String signature = RequestSigner.sign("POST", "/api/devices/" + id + "/revoke", null, + request); + if (signature == null) { + return; + } + Api.banking().revokeDevice(id, signature, request, Net.to((DeviceDto gone) -> { + if (id.equals(orphan())) { + Preferences.delete(ORPHAN_KEY); + } + }, (status, message) -> { + // 404: already gone (or never this account's). Anything else is + // tried again next time. + if (status == 404 && id.equals(orphan())) { + Preferences.delete(ORPHAN_KEY); + } + })); + } + + private static AssociatedData aad(String owner) { + return AssociatedData.of("bastion", "device", owner, "request-signing"); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Keys.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Keys.java new file mode 100644 index 00000000000..c2e9e1972d8 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Keys.java @@ -0,0 +1,538 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.security; + +import com.codename1.io.Preferences; +import com.codename1.security.BiometricError; +import com.codename1.security.BiometricException; +import com.codename1.security.SecureRandom; +import com.codename1.security.SecureStorage; +import com.codename1.security.vault.KdfProfile; +import com.codename1.security.vault.UnlockPolicy; +import com.codename1.security.vault.Vault; +import com.codename1.security.vault.VaultError; +import com.codename1.security.vault.VaultException; +import com.codename1.security.vault.VaultOptions; +import com.codename1.util.Base64; +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; + +/// The app key: 32 random bytes that seal everything Bastion keeps on the +/// device -- the payment-signing key ([DeviceKey]) and the session's refresh +/// token ([SealedStorage]). It is in memory only while the app is unlocked; +/// locking drops it, and nothing sealed under it can be read until one of +/// its copies is opened again: +/// +/// - **Biometric copy.** In [SecureStorage]'s prompting tier, +/// `get(reason, account)`: on iOS a Keychain item with +/// `kSecAccessControlTouchIDCurrentSet`, on Android a Keystore key with +/// user authentication required. The operating system releases it only +/// after a fingerprint or face check, and enrolling another biometric +/// revokes it. The app never decides "the check passed"; the key either +/// comes back or it does not. +/// - **PIN copy.** A [Vault] enrolled with the PIN, through the vault's +/// [KdfProfile]. The PIN is joined with a random pepper kept in the quiet, +/// OS-protected tier, so a copy of the app's files is not enough to try +/// PINs offline: that also needs the Keychain or Keystore. On the device, +/// [#PIN_ATTEMPTS] wrong PINs in a row wipe every copy. +/// - **Plain copy.** Only while neither of the above is set up: the key in +/// the quiet tier, as protected as the OS store is, opened without asking. +/// The lock then has nothing to ask for and signs the session out instead. +/// +/// Turning a protection on removes the plain copy, so the weaker way in does +/// not sit beside the stronger one. +/// +/// The attempt counter lives in the OS secure store beside the pepper +/// ([#pinFailures]), so a copy or rollback of the app's files does not reset +/// it. It is still a PIN checked on the device: someone who controls the OS +/// store controls the counter too, which is why the pepper is there as well. +public final class Keys { + /// The shortest PIN the app accepts. + public static final int MIN_PIN = 6; + /// The longest. + public static final int MAX_PIN = 12; + /// Wrong PINs in a row before every copy of the key is wiped. + public static final int PIN_ATTEMPTS = 5; + + private static final String PLAIN_ACCOUNT = "bastion.keys.plain"; + private static final String BIO_ACCOUNT = "bastion.keys.biometric"; + private static final String PEPPER_ACCOUNT = "bastion.keys.pepper"; + private static final String MADE = "bastion.keys.made"; + private static final String BIO_ON = "bastion.keys.biometricOn"; + private static final String PIN_ON = "bastion.keys.pinOn"; + private static final String FAILURES = "bastion.keys.pinFailures"; + private static final String OWNER = "bastion.keys.owner"; + /// Set while a PIN vault is known to be on the device but its removal has + /// not been confirmed: the next wipe tries again. + private static final String VAULT_STALE = "bastion.keys.vaultStale"; + private static final String VAULT = "bastion-pin"; + private static final String SECRET = "app-key"; + + /// What a PIN attempt came to. + public interface PinResult { + /// The key is open. + void unlocked(); + + /// Wrong PIN; `left` more tries before the wipe. + void wrong(int left); + + /// Too many wrong PINs: every copy is gone, sign in again. + void wiped(); + + /// The vault could not be read (not the PIN's fault). + void failed(String why); + } + + private static byte[] key; + private static SecureStorage storage; + + private Keys() { + } + + /// Keeps the copies somewhere else (the tests keep them in memory). + public static void useStorage(SecureStorage store) { + storage = store; + } + + static SecureStorage storage() { + if (storage != null) { + return storage; + } + SecureStorage platform = SecureStorage.getInstance(); + // The strict binding is opt-in (installed apps keep their keys): on + // Android it binds the key on every API level and revokes it when a + // biometric is enrolled. Set before the first write creates the key: + // a key made before it (an update of an app that ran without it) + // stays unbound, and isStrictBiometricBinding() still answers true. + platform.setStrictBiometricBinding(true); + return platform; + } + + /// The key while unlocked, else null. Callers must not keep it. + public static byte[] current() { + return key; + } + + /// Whether a key was ever made on this device (and not wiped since). + public static boolean exists() { + return Preferences.get(MADE, false); + } + + public static boolean isOpen() { + return key != null; + } + + public static boolean biometricEnabled() { + return Preferences.get(BIO_ON, false); + } + + public static boolean pinEnabled() { + return Preferences.get(PIN_ON, false); + } + + /// Whether opening the key needs the user: a biometric or the PIN. + public static boolean protectedByUser() { + return biometricEnabled() || pinEnabled(); + } + + /// Wrong PINs since the last good one. + /// + /// Kept in the OS secure store beside the pepper, not in Preferences: a + /// plain file is one a thief with the phone's storage (a backup, a rooted + /// shell) could reset between guesses, and the count is what turns five + /// wrong PINs into a wipe. Removing the secure store's entry resets it + /// too, but that store is also where the pepper lives, so the same + /// access already loses the vault. + public static int pinFailures() { + String stored = storage().get(FAILURES); + if (stored == null) { + return 0; + } + try { + return Integer.parseInt(stored); + } catch (NumberFormatException damaged) { + // Unreadable counts as the last try, never as none. + return PIN_ATTEMPTS - 1; + } + } + + private static void setPinFailures(int failures) { + storage().set(FAILURES, Integer.toString(failures)); + } + + private static void clearPinFailures() { + storage().remove(FAILURES); + // The copy an earlier version kept in Preferences. + Preferences.delete(FAILURES); + } + + /// Drops the key from memory: the app lock calls this. + public static void lock() { + zero(key); + key = null; + } + + /// After a fresh sign-in. Opens the plain copy if there is one; makes a + /// key if there is none. A key that exists but needs a biometric or PIN + /// the user did not give is given up: the sign-in proves who the user is + /// but cannot open it, so the device starts over (its signing key is + /// registered again when a payment needs it). + public static void signedIn() { + if (key != null) { + return; + } + if (Lock.isLocked()) { + // Never while locked: a refresh that failed behind the lock is + // not the user choosing to start over. The unlock decides. + return; + } + if (exists() && !protectedByUser() && openPlain()) { + return; + } + startOver(); + } + + /// After a sign-in, once the server has said who `username` is. A key + /// made for another account -- or a device key registered for one -- is + /// given up: the next user must not have their session sealed under the + /// previous user's key, nor sign with the previous user's device id. + /// Answers true when the key was replaced, and whatever was sealed under + /// the old one (the tokens just stored) has to be sealed again. + public static boolean claim(String username) { + if (username == null) { + return false; + } + String owner = Preferences.get(OWNER, ""); + String deviceOwner = DeviceKey.owner(); + boolean foreign = (owner.length() > 0 && !owner.equals(username)) + || (deviceOwner != null && !deviceOwner.equals(username)); + if (foreign) { + startOver(); + } + Preferences.set(OWNER, username); + return foreign; + } + + /// Opens the plain copy without asking: only when the user set up + /// neither a biometric nor a PIN. + public static boolean openPlain() { + if (protectedByUser()) { + return false; + } + byte[] stored = decode(storage().get(PLAIN_ACCOUNT)); + if (stored == null) { + return false; + } + key = stored; + return true; + } + + /// Wipes every copy and makes a new key, held as a plain copy until the + /// user protects it. Whatever was sealed under the old key is unreadable + /// from here: the device key is forgotten with it. + public static void startOver() { + wipe(); + key = SecureRandom.bytes(32); + storage().set(PLAIN_ACCOUNT, encode(key)); + Preferences.set(MADE, true); + } + + /// Wipes every copy and the key in memory. Signing out calls this too: + /// the next account to sign in on this device starts from nothing. + /// + /// Every copy is removed whether or not the preferences say it was set + /// up: a flag that was lost or cleared (a revoked biometric, an + /// interrupted setup) must not leave a copy behind. The PIN vault's + /// removal is asynchronous; the answer completes when it is done, with + /// false when it failed -- the vault is then marked and removed again by + /// the next wipe or PIN setup, and is useless meanwhile: its pepper is + /// already gone. + public static AsyncResource wipe() { + lock(); + SecureStorage store = storage(); + store.remove(PLAIN_ACCOUNT); + store.remove(PEPPER_ACCOUNT); + store.remove(null, BIO_ACCOUNT); + Preferences.delete(MADE); + Preferences.delete(BIO_ON); + Preferences.delete(PIN_ON); + clearPinFailures(); + Preferences.delete(OWNER); + DeviceKey.forget(); + final AsyncResource out = new AsyncResource(); + final Vault vault = vault(); + if (vault == null || (!vault.isEnrolled() && !Preferences.get(VAULT_STALE, false))) { + Preferences.delete(VAULT_STALE); + out.complete(Boolean.TRUE); + return out; + } + Preferences.set(VAULT_STALE, true); + vault.destroyLocalData().ready(gone -> { + Preferences.delete(VAULT_STALE); + out.complete(Boolean.TRUE); + }).except(err -> out.complete(Boolean.FALSE)); + return out; + } + + private static Vault testVault; + private static boolean useTestVault; + + /// Keeps the PIN vault somewhere else (the tests); null for none. + static void useVault(Vault vault) { + testVault = vault; + useTestVault = true; + } + + private static Vault vault() { + return useTestVault ? testVault : Vault.named(VAULT); + } + + // ---------------------------------------------------------- biometric + + /// Stores the biometric copy (Android asks for the biometric to write + /// it). `failed` gets a reason to show when the platform has no + /// biometric-bound store or the user cancelled. + public static void enableBiometric(String reason, final Runnable done, + final SuccessCallback failed) { + if (key == null) { + failed.onSucess("Unlock first."); + return; + } + storage().set(reason, BIO_ACCOUNT, encode(key)).ready(ok -> { + if (ok != null && ok.booleanValue()) { + Preferences.set(BIO_ON, true); + storage().remove(PLAIN_ACCOUNT); + done.run(); + } else { + failed.onSucess("This device could not keep a biometric-bound key."); + } + }).except(err -> failed.onSucess(unavailable(err) + ? "This device has no biometric-bound key store." + : "Biometric setup was cancelled.")); + } + + /// Removes the biometric copy; with no PIN either, the plain copy comes + /// back so the key is not lost. + public static void disableBiometric() { + if (!biometricEnabled()) { + return; + } + storage().remove(null, BIO_ACCOUNT); + Preferences.delete(BIO_ON); + restorePlainIfUnprotected(); + } + + /// Opens the key with a biometric: the OS prompts, and only a match + /// releases the copy. `failed` gets true when the copy is gone for good + /// (a new fingerprint was enrolled) and the PIN or a sign-in is next. + public static void unlockWithBiometric(String reason, final Runnable done, + final SuccessCallback failed) { + if (!biometricEnabled()) { + failed.onSucess(Boolean.TRUE); + return; + } + storage().get(reason, BIO_ACCOUNT).ready(stored -> { + byte[] opened = decode(stored); + if (opened == null) { + failed.onSucess(Boolean.FALSE); + return; + } + key = opened; + done.run(); + }).except(err -> { + boolean gone = err instanceof BiometricException + && ((BiometricException) err).getError() == BiometricError.KEY_REVOKED; + if (gone) { + // Revoked by the OS: a biometric was enrolled since. The copy + // is dead; say so instead of offering it again, and remove + // the entry itself -- biometricEnabled() is false from here, + // so nothing else would. + Preferences.delete(BIO_ON); + storage().remove(null, BIO_ACCOUNT); + restorePlainIfUnprotected(); + } + failed.onSucess(Boolean.valueOf(gone)); + }); + } + + // ---------------------------------------------------------------- PIN + + /// Whether `pin` is acceptable: [#MIN_PIN] to [#MAX_PIN] digits, not one + /// digit repeated and not a run up or down (123456, 987654). + public static boolean validPin(String pin) { + if (pin == null || pin.length() < MIN_PIN || pin.length() > MAX_PIN) { + return false; + } + boolean same = true; + boolean up = true; + boolean down = true; + for (int iter = 0; iter < pin.length(); iter++) { + char c = pin.charAt(iter); + if (c < '0' || c > '9') { + return false; + } + if (iter > 0) { + int step = c - pin.charAt(iter - 1); + same &= step == 0; + up &= step == 1; + down &= step == -1; + } + } + return !same && !up && !down; + } + + /// Sets (or replaces) the PIN copy. The key must be open. + public static void setPin(final String pin, final Runnable done, + final SuccessCallback failed) { + if (key == null) { + failed.onSucess("Unlock first."); + return; + } + if (!validPin(pin)) { + failed.onSucess("Choose 6 to 12 digits that are not a simple run."); + return; + } + final Vault vault = vault(); + final char[] secret = encode(key).toCharArray(); + Runnable enroll = () -> { + String pepper = encode(SecureRandom.bytes(32)); + if (!storage().set(PEPPER_ACCOUNT, pepper)) { + failed.onSucess("This device has no secure storage for the PIN."); + return; + } + vault.enroll(pinSecret(pin, pepper), new VaultOptions() + .kdf(KdfProfile.current()) + .policy(UnlockPolicy.SESSION_ONLY)) + .ready(enrolled -> vault.putSecret(SECRET, secret).ready(stored -> { + vault.lock(); + Preferences.set(PIN_ON, true); + clearPinFailures(); + storage().remove(PLAIN_ACCOUNT); + done.run(); + }).except(err -> failed.onSucess("The PIN could not be saved."))) + .except(err -> failed.onSucess("The PIN could not be saved.")); + }; + if (vault.isEnrolled()) { + vault.destroyLocalData().ready(gone -> enroll.run()) + .except(err -> failed.onSucess("The old PIN could not be removed.")); + } else { + enroll.run(); + } + } + + /// Removes the PIN copy; with no biometric either, the plain copy comes + /// back. `failed` gets the reason, untranslated, when the vault could not + /// be destroyed: the PIN then still unlocks, and saying otherwise would + /// leave the customer believing it is gone. + public static void removePin(final Runnable done, final SuccessCallback failed) { + if (!pinEnabled()) { + done.run(); + return; + } + vault().destroyLocalData().ready(gone -> { + storage().remove(PEPPER_ACCOUNT); + Preferences.delete(PIN_ON); + clearPinFailures(); + restorePlainIfUnprotected(); + done.run(); + }).except(err -> failed.onSucess("The PIN could not be removed.")); + } + + /// Opens the key with the PIN. A wrong one counts; [#PIN_ATTEMPTS] in a + /// row wipe every copy of the key. + public static void unlockWithPin(String pin, final PinResult result) { + if (!pinEnabled()) { + result.failed("No PIN is set on this device."); + return; + } + final String pepper = storage().get(PEPPER_ACCOUNT); + if (pepper == null) { + // Without the pepper no PIN can be right: the OS store lost it. + wipe().ready(done -> result.wiped()); + return; + } + final Vault vault = vault(); + vault.unlockWithPassword(pinSecret(pin, pepper)).ready(ok -> vault.getSecret(SECRET) + .ready(chars -> { + vault.lock(); + byte[] opened = chars == null ? null : decode(new String(chars)); + if (opened == null) { + result.failed("The PIN vault is damaged."); + return; + } + clearPinFailures(); + key = opened; + result.unlocked(); + }).except(err -> { + vault.lock(); + result.failed("The PIN vault could not be read."); + })).except(err -> { + if (err instanceof VaultException + && ((VaultException) err).getError() == VaultError.AUTHENTICATION_FAILED) { + int failures = pinFailures() + 1; + if (failures >= PIN_ATTEMPTS) { + wipe().ready(done -> result.wiped()); + } else { + setPinFailures(failures); + result.wrong(PIN_ATTEMPTS - failures); + } + } else { + result.failed("The PIN vault could not be read."); + } + }); + } + + // ------------------------------------------------------------ helpers + + /// What the vault's KDF is given: the PIN and the device's pepper. + static char[] pinSecret(String pin, String pepper) { + return (pin + ":" + pepper).toCharArray(); + } + + private static void restorePlainIfUnprotected() { + if (!protectedByUser() && key != null) { + storage().set(PLAIN_ACCOUNT, encode(key)); + } + } + + private static boolean unavailable(Throwable err) { + return err instanceof BiometricException + && ((BiometricException) err).getError() == BiometricError.NOT_AVAILABLE; + } + + private static String encode(byte[] bytes) { + return Base64.encodeUrlSafe(bytes); + } + + private static byte[] decode(String stored) { + if (stored == null) { + return null; + } + byte[] bytes = Base64.decodeUrlSafe(stored); + return bytes != null && bytes.length == 32 ? bytes : null; + } + + private static void zero(byte[] bytes) { + if (bytes != null) { + for (int iter = 0; iter < bytes.length; iter++) { + bytes[iter] = 0; + } + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Lock.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Lock.java new file mode 100644 index 00000000000..65ee69c9c37 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Lock.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.security; + +import com.codename1.security.Biometrics; +import com.codename1.ui.CN; +import com.codename1.ui.Display; +import com.codename1.ui.Dialog; +import com.codename1.ui.animations.CommonTransitions; +import com.codename1.ui.Form; +import com.codename1.util.SuccessCallback; +import com.codenameone.examples.bastion.AppConfig; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.ui.Lang; + +/// The app lock: when the app starts with a stored session, when it comes +/// back from the background after [AppConfig#BACKGROUND_LOCK_MILLIS], and +/// after [AppConfig#IDLE_LOCK_MILLIS] without a touch. +/// +/// Locked, nothing of the account is on screen (the lock screen replaces the +/// form), and the app key ([Keys]) and the unsealed device key are dropped +/// from memory, so nothing can sign a payment or read the stored refresh +/// token until a copy of the key is opened again: by a biometric the OS +/// checks, else by the app PIN, else not at all -- the lock then signs the +/// session out, rather than letting the device passcode stand in for the +/// bank's. +public final class Lock { + /// What to show while locked, and what to show once unlocked. + public interface Screens { + void showLocked(); + + void showUnlocked(Form previous); + } + + private static Screens screens; + private static boolean locked; + private static long lastTouch = System.currentTimeMillis(); + private static long backgroundSince; + private static Form hidden; + private static boolean ticking; + private static Form lastForm; + + private Lock() { + } + + public static void install(Screens app) { + screens = app; + } + + /// Called by every form the app shows: a touch anywhere is activity. + /// + /// The idle check is a timer of its own, not a UITimer: a UITimer runs + /// only while the form it is bound to is showing, so an open dialog -- + /// a Dialog is a form of its own -- stopped it, and an app left on a + /// confirmation never locked. `CN.setTimeout` belongs to no form (and, + /// unlike a UITimer bound to no form, works in the browser), so it is + /// armed once, by the first form, and re-armed by every check. + public static void watch(Form form) { + listen(form); + lastForm = form; + if (!ticking) { + ticking = true; + CN.setTimeout(IDLE_CHECK_MILLIS, Lock::tick); + } + } + + private static final int IDLE_CHECK_MILLIS = 5000; + + private static void listen(Form form) { + if (form.getClientProperty("bastion.lock.watched") != null) { + return; + } + form.putClientProperty("bastion.lock.watched", Boolean.TRUE); + form.addPointerPressedListener(evt -> touched()); + form.addPointerDraggedListener(evt -> touched()); + } + + private static void tick() { + try { + // A dialog is a form nobody called watch for: a touch in it counts too. + Form current = CN.getCurrentForm(); + if (current instanceof Dialog) { + listen(current); + } + checkIdle(); + } finally { + // Re-armed whatever happened: an exception here must not end + // the idle lock for the rest of the run. + CN.setTimeout(IDLE_CHECK_MILLIS, Lock::tick); + } + } + + public static void touched() { + lastTouch = System.currentTimeMillis(); + } + + public static boolean isLocked() { + return locked; + } + + /// Whether this device can unlock with a fingerprint or face. + public static boolean biometricsAvailable() { + Biometrics bio = Biometrics.getInstance(); + return bio.isSupported() && bio.canAuthenticate(); + } + + /// Locks now, if there is a session to protect. + public static void lock() { + if (locked || !Session.hasTokens() || screens == null) { + return; + } + locked = true; + DeviceKey.dropFromMemory(); + Keys.lock(); + // The tokens go too: nothing renews behind the lock (the rotated + // refresh token could not be sealed while the key is closed, and + // would be lost), every answer still on its way is dropped (the + // session epoch moves), and the unlock reads them back from the + // sealed store (Session#restore). + Session.suspend(); + Form current = CN.getCurrentForm(); + if (current instanceof Dialog) { + // A dialog cannot be put back after the lock: it is closed (as if + // cancelled) and the unlock returns to the form it was over. + // Without its closing animation: the form it returns to becomes + // current only for the moment before showLocked replaces it, in + // the same EDT call, so nothing beneath is ever painted. + Dialog dialog = (Dialog) current; + dialog.setTransitionOutAnimator(CommonTransitions.createEmpty()); + dialog.dispose(); + current = lastForm; + } + hidden = current; + screens.showLocked(); + } + + /// A form something asked to show while the app is locked -- an answer + /// that arrived after the lock. It is not shown; it becomes the form the + /// unlock goes back to. + public static void defer(Form form) { + hidden = form; + } + + /// Locks at start, before the stored session is read: its refresh token + /// is sealed under the key the unlock opens. + public static void lockAtStart() { + if (screens == null) { + return; + } + locked = true; + hidden = null; + screens.showLocked(); + } + + /// The app went to the background. + public static void paused() { + backgroundSince = System.currentTimeMillis(); + Protect.paused(); + } + + /// The app is in front again. + public static void resumed() { + if (backgroundSince > 0 + && System.currentTimeMillis() - backgroundSince >= AppConfig.BACKGROUND_LOCK_MILLIS) { + lock(); + } + backgroundSince = 0; + touched(); + } + + private static void checkIdle() { + if (!locked && System.currentTimeMillis() - lastTouch >= AppConfig.IDLE_LOCK_MILLIS) { + lock(); + } + } + + /// Asks the OS for the biometric copy of the app key: the prompt is the + /// OS's, and only a match releases the key. `failed` gets true when that + /// copy is gone for good and the PIN (or a sign-in) is the way in. + public static void unlockWithBiometric(final SuccessCallback failed) { + Keys.unlockWithBiometric(Lang.tr("Unlock Bastion and the keys that sign your payments"), + Lock::unlocked, failed); + } + + /// Tries the app PIN; see [Keys#unlockWithPin]. + public static void unlockWithPin(String pin, final Keys.PinResult result) { + Keys.unlockWithPin(pin, new Keys.PinResult() { + @Override + public void unlocked() { + result.unlocked(); + Lock.unlocked(); + } + + @Override + public void wrong(int left) { + result.wrong(left); + } + + @Override + public void wiped() { + result.wiped(); + } + + @Override + public void failed(String why) { + result.failed(why); + } + }); + } + + /// Ends the lock without showing anything: after a fresh sign-in. + public static void reset() { + locked = false; + hidden = null; + touched(); + } + + static void unlocked() { + locked = false; + touched(); + Form previous = hidden; + hidden = null; + if (screens != null) { + screens.showUnlocked(previous); + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Protect.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Protect.java new file mode 100644 index 00000000000..1a92ac53f78 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Protect.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.security; + +import com.codename1.io.Preferences; +import com.codename1.security.DeviceIntegrity; +import com.codename1.security.Hmac; +import com.codename1.security.SecureRandom; +import com.codename1.security.TapjackingPolicy; +import com.codename1.security.shield.ShieldSignal; +import com.codename1.security.shield.ShieldSignals; +import com.codename1.ui.CN; +import com.codename1.ui.ClipboardContent; +import com.codename1.ui.Display; +import com.codename1.util.Base64; +import com.codenameone.examples.bastion.AppConfig; + +/// Screen and input protection. +/// +/// - **Sensitive screens** (balances, card numbers, codes) set the platform's +/// secure-window flag while they are showing: no screenshots, no screen +/// recording, and a blank card in the recent-apps list (Android +/// `FLAG_SECURE`; on iOS, `ios.blockScreenshotsOnEnterBackground` covers the +/// app switcher). +/// - **Tapjacking**: a touch that arrives through another app's overlay is +/// dropped ([TapjackingPolicy#BLOCK]), and overlay windows are hidden while +/// the app is in front where the OS allows it (Android 12+). +/// - **Clipboard**: an account number, address or authenticator key the user +/// copies is wiped from the clipboard after +/// [AppConfig#CLIPBOARD_CLEAR_MILLIS], whether or not the app is in the +/// foreground (the background is not a trigger: that would need a read), +/// and when the app next shows a screen if the clear could not run in time +/// -- unless something else was copied in the meantime. The clip is marked sensitive +/// ([ClipboardContent#setSensitive(boolean)]): Android 13 and newer keep it +/// out of the clipboard preview and the keyboard's suggestions, and iOS +/// keeps it off Universal Clipboard and lets the pasteboard expire it even +/// if the app is killed first. +public final class Protect { + /// The leftover check's verdicts ([#decide]). + static final int KEEP = 0; + static final int CLEAR = 1; + static final int FORGET = 2; + + private static final String LEFT = "bastion.clipboard.left"; + private static final String KEY_ACCOUNT = "bastion.clipboard.key"; + private static boolean secure; + + private Protect() { + } + + public static void install() { + DeviceIntegrity.setTapjackingProtection(TapjackingPolicy.BLOCK); + if (DeviceIntegrity.isHideOverlayWindowsSupported()) { + DeviceIntegrity.setHideOverlayWindows(true); + } + DeviceIntegrity.addTapjackingListener(evt -> + ShieldSignals.add(ShieldSignal.TAPJACK, 2, "a touch through an overlay was blocked")); + // Not now: runApp has not shown a form yet, and Android 10+ answers + // a clipboard read with null until a window of ours has focus. The + // first screen() call and every resume look again. + CN.callSerially(Protect::checkLeftover); + } + + /// Called as each form shows: `sensitive` forms block screenshots. + public static void screen(boolean sensitive) { + if (sensitive != secure) { + secure = sensitive; + DeviceIntegrity.setSecureScreen(sensitive); + } + if (Preferences.get(LEFT, "").length() > 0) { + // After the show, so the window has focus by the time it reads. + CN.callSerially(Protect::checkLeftover); + } + } + + public static boolean isSecureScreen() { + return secure; + } + + /// Copies `text`, and clears it again later. + public static void copy(final String text) { + Display.getInstance().copyToClipboard(new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, text) + .setSensitive(true) + .setClearAfter((int) AppConfig.CLIPBOARD_CLEAR_MILLIS)); + if (isIos()) { + // The pasteboard expires a sensitive clip itself, even in a + // killed app; reading it back later would only show the paste + // banner. + return; + } + // A keyed digest, never the text: what was left on the clipboard is + // recognised later without keeping it anywhere, and an account + // number has too few possible values for a plain hash to hide it. + byte[] key = digestKey(); + if (key == null) { + return; + } + long due = System.currentTimeMillis() + AppConfig.CLIPBOARD_CLEAR_MILLIS; + Preferences.set(LEFT, due + ":" + digest(key, text)); + // The content clears itself (setClearAfter): one mechanism writes the + // clipboard when the time is up, never two racing each other. The + // digest stays until a read says the clip is gone or was replaced, + // because the port defers a clear that falls due in the background + // and a process killed meanwhile never runs it. This timer only + // looks; CN.setTimeout, not a UITimer, which runs only while its + // form is showing. + CN.setTimeout((int) AppConfig.CLIPBOARD_CLEAR_MILLIS + 1000, Protect::checkLeftover); + } + + /// The app came back to the foreground. + public static void resumed() { + CN.callSerially(Protect::checkLeftover); + } + + /// The app went to the background. Nothing is read or written here: + /// reading the clipboard to see whether our copy is still on it shows the + /// iOS paste banner and reports a read to Android 12+, and writing without + /// reading could wipe what the user copied in another app since. The + /// clip's own clear (setClearAfter, which clears only while nothing newer + /// was copied, and the pasteboard expiry on iOS) keeps running, and the + /// digest left by copy() lets the next look clear it if the app is + /// killed first. + public static void paused() { + // Deliberately empty; kept so the lifecycle call site documents it. + } + + /// A copy whose clear may not have run: cleared if it is still there. + static void checkLeftover() { + String left = Preferences.get(LEFT, ""); + if (left.length() == 0) { + return; + } + if (isIos()) { + Preferences.delete(LEFT); + return; + } + if (!due(left, System.currentTimeMillis())) { + return; + } + byte[] key = digestKey(); + String now = clipText(); + int verdict = key == null ? FORGET : decide(left, now == null ? null : digest(key, now)); + if (verdict == CLEAR) { + Display.getInstance().copyToClipboard(""); + } + if (verdict != KEEP) { + Preferences.delete(LEFT); + } + } + + /// Whether the clear recorded in `left` has fallen due by `now`. + static boolean due(String left, long now) { + int colon = left.indexOf(':'); + if (colon <= 0) { + return true; + } + try { + return now >= Long.parseLong(left.substring(0, colon)); + } catch (NumberFormatException bad) { + return true; + } + } + + /// What to do with the record `left` given the digest of the clipboard + /// now (null when the read answered nothing, which on Android 10+ means + /// "not allowed yet", not "empty"): keep it for the next look, clear the + /// clipboard, or forget it because something else was copied since. + static int decide(String left, String nowDigest) { + if (nowDigest == null) { + return KEEP; + } + String stored = left.substring(left.indexOf(':') + 1); + return stored.equals(nowDigest) ? CLEAR : FORGET; + } + + /// The text on the clipboard, whichever form the port answers it in. + private static String clipText() { + Object now = Display.getInstance().getPasteDataFromClipboard(); + if (now instanceof ClipboardContent) { + return ((ClipboardContent) now).getText(ClipboardContent.MIME_TEXT); + } + return now instanceof String ? (String) now : null; + } + + /// The HMAC key, made once and kept in secure storage, never in + /// Preferences beside the digest; null if secure storage refuses it. + static byte[] digestKey() { + String stored = Keys.storage().get(KEY_ACCOUNT); + if (stored != null && stored.length() > 0) { + return Base64.decode(stored.getBytes()); + } + byte[] made = SecureRandom.bytes(32); + return Keys.storage().set(KEY_ACCOUNT, Base64.encodeNoNewline(made)) ? made : null; + } + + static String digest(byte[] key, String text) { + try { + return Base64.encodeUrlSafe(Hmac.sha256(key, text.getBytes("UTF-8"))); + } catch (java.io.UnsupportedEncodingException impossible) { + return ""; + } + } + + private static boolean isIos() { + return "ios".equals(CN.getPlatformName()); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/RequestSigner.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/RequestSigner.java new file mode 100644 index 00000000000..13dadf95267 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/RequestSigner.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.security; + +import com.codename1.mapping.Mappers; +import com.codename1.security.Hash; +import com.codename1.security.PrivateKey; +import com.codename1.security.SecureRandom; +import com.codename1.security.Signature; +import com.codename1.util.Base64; +import com.codenameone.examples.bastion.api.SignedRequest; +import com.codenameone.examples.bastion.net.Session; + +import java.io.UnsupportedEncodingException; + +/// Signs a request with the device key, in the form the server checks +/// ([SignedRequest], shared with it). +/// +/// The signature covers the exact body bytes, so it is made over the very +/// text the typed client sends: the generated client writes a `@Body` as +/// `Mappers.toJson(body)` in UTF-8, and [#sign(String, String, String, Object)] hashes +/// that same string. Sign the object you then pass, unchanged, to the call. +public final class RequestSigner { + private RequestSigner() { + } + + /// The `Bastion-Signature` header for `method target` carrying `body` (a + /// mapped DTO, or null for no body) under `idempotencyKey` (the request's + /// `Idempotency-Key`, or null when it has none), or null when this device + /// has no registered key yet -- the server then answers 403 for anything + /// that needs one, and the app offers to register the device. + /// + /// Also null when the key was registered for another account than the one + /// signed in: it would sign this user's payment under the previous user's + /// device id. [Keys#claim] gives such a key up after a sign-in; this is the + /// last line in case anything ever skips it. + public static String sign(String method, String target, String idempotencyKey, + Object body) { + String deviceId = DeviceKey.deviceId(); + if (deviceId == null || !DeviceKey.ownedBy(Session.username())) { + return null; + } + PrivateKey key = DeviceKey.privateKey(); + if (key == null) { + return null; + } + return header(deviceId, key, method, target, idempotencyKey, bodyBytes(body), nonce(), + System.currentTimeMillis()); + } + + /// The bytes the generated client sends for `body`. + public static byte[] bodyBytes(Object body) { + if (body == null) { + return new byte[0]; + } + return utf8(Mappers.toJson(body)); + } + + /// The header for the given parts; the deterministic core of + /// [#sign(String, String, String, Object)], which the tests check against the + /// server's form. + public static String header(String deviceId, PrivateKey privateKey, String method, + String target, String idempotencyKey, byte[] body, String nonce, long ts) { + String text = SignedRequest.canonical(method, target, Hash.sha256(body), idempotencyKey, + nonce, ts); + byte[] der = Signature.sign(Signature.SHA256_WITH_ECDSA, privateKey, utf8(text)); + return SignedRequest.header(deviceId, ts, nonce, Base64.encodeUrlSafe(jose(der))); + } + + /// The DER `SEQUENCE { INTEGER r, INTEGER s }` the platform signs into, as + /// the 64-byte JOSE `r || s` the header carries (ES256, RFC 7518 3.4). + static byte[] jose(byte[] der) { + if (der == null || der.length < 8 || der[0] != 0x30) { + throw new IllegalArgumentException("not a DER ECDSA signature"); + } + int at = (der[1] & 0x80) != 0 ? 2 + (der[1] & 0x7f) : 2; + byte[] out = new byte[64]; + for (int part = 0; part < 2; part++) { + if (der[at] != 0x02) { + throw new IllegalArgumentException("not a DER ECDSA signature"); + } + int len = der[at + 1] & 0xff; + int start = at + 2; + int from = start; + int n = len; + while (n > 32 && der[from] == 0) { + from++; + n--; + } + if (n > 32) { + throw new IllegalArgumentException("not a P-256 signature"); + } + System.arraycopy(der, from, out, part * 32 + 32 - n, n); + at = start + len; + } + return out; + } + + /// 128 random bits, base64url: 22 characters, in the server's alphabet. + public static String nonce() { + return Base64.encodeUrlSafe(SecureRandom.bytes(16)); + } + + /// A fresh `Idempotency-Key` for one money action. Made once when the + /// user starts the action and reused for every retry of it, so a retry + /// after a lost answer is answered with the first receipt instead of + /// moving the money twice. + public static String idempotencyKey() { + return Base64.encodeUrlSafe(SecureRandom.bytes(18)); + } + + static byte[] utf8(String text) { + try { + return text.getBytes("UTF-8"); + } catch (UnsupportedEncodingException impossible) { + throw new IllegalStateException("UTF-8 is missing"); + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/SealedStorage.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/SealedStorage.java new file mode 100644 index 00000000000..1ba0f565bda --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/SealedStorage.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.security; + +import com.codename1.security.SecureStorage; +import com.codename1.security.vault.AssociatedData; +import com.codename1.security.vault.ProtectionReport; +import com.codename1.security.vault.SecureEnvelope; +import com.codename1.util.Base64; + +/// The store the session's tokens are kept in: each value sealed under the +/// app key ([Keys]) as an AES-256-GCM [SecureEnvelope], and the sealed text +/// kept in the OS store's quiet tier. +/// +/// So the refresh token is under the same lock as the payment key: with a +/// biometric or PIN set up, a stolen phone that is not unlocked has a +/// ciphertext whose key the OS will not release, not a token that can be +/// replayed. While the app is locked [#get(String)] answers null and +/// [#set(String, String)] refuses; the session reads its tokens after the +/// unlock (`Bastion.runApp`). +/// +/// Only the quiet methods are implemented: the token store never asks this +/// store for a biometric -- the biometric is what opens the key. +public final class SealedStorage extends SecureStorage { + private final SecureStorage inner; + + public SealedStorage(SecureStorage inner) { + this.inner = inner; + } + + @Override + public boolean set(String account, String value) { + byte[] key = Keys.current(); + if (key == null) { + return false; + } + byte[] sealed = SecureEnvelope.seal(key, "app-key", 1, aad(account), + bytes(value)); + return inner.set(account, Base64.encodeUrlSafe(sealed)); + } + + @Override + public String get(String account) { + byte[] key = Keys.current(); + String stored = inner.get(account); + if (key == null || stored == null) { + return null; + } + try { + byte[] opened = SecureEnvelope.parse(Base64.decodeUrlSafe(stored)) + .open(key, aad(account)); + return text(opened); + } catch (RuntimeException sealedUnderAnotherKey) { + // A token sealed under a key since wiped: no session to restore. + remove(account); + return null; + } + } + + @Override + public boolean remove(String account) { + return inner.remove(account); + } + + @Override + public int entryState(String account) { + return inner.entryState(account); + } + + @Override + public ProtectionReport protection() { + return inner.protection(); + } + + private static AssociatedData aad(String account) { + return AssociatedData.of("bastion", "session", account, "oidc-tokens"); + } + + private static byte[] bytes(String value) { + try { + return value.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException never) { + return value.getBytes(); + } + } + + private static String text(byte[] bytes) { + try { + return new String(bytes, "UTF-8"); + } catch (java.io.UnsupportedEncodingException never) { + return new String(bytes); + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/SecurityStatus.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/SecurityStatus.java new file mode 100644 index 00000000000..a822b9c1b2a --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/SecurityStatus.java @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.security; + +import com.codename1.security.DeviceIntegrity; +import com.codename1.security.SecureStorage; +import com.codename1.security.vault.KdfProfile; +import com.codename1.security.TapjackingPolicy; +import com.codename1.security.hardening.Hardening; +import com.codename1.security.shield.AppShield; +import com.codename1.security.shield.PinSet; +import com.codename1.security.vault.Protection; +import com.codename1.security.vault.ProtectionReport; +import com.codename1.ui.CN; +import com.codenameone.examples.bastion.AppConfig; +import com.codenameone.examples.bastion.net.Guard; +import com.codenameone.examples.bastion.net.Session; + +import java.util.ArrayList; +import java.util.List; + +/// What the Security Center lists: every protection the app has, and whether +/// it is really in force on this device, in this build, right now. +/// +/// Honesty is the point. A protection that only a cloud build applies (code +/// hardening, attestation) says so instead of claiming to be on; one the +/// simulator cannot provide (a hardware keystore, secure windows) says that; +/// a development exemption in use is a warning, in red. +public final class SecurityStatus { + /// In force. + public static final int ACTIVE = 0; + /// Not in force on this device: the simulator or desktop cannot provide it. + public static final int SIMULATOR = 1; + /// Applied only by a build from the Codename One build server (or with an + /// entitlement that build carries). + public static final int CLOUD_BUILD = 2; + /// Off, or weakened, and the user should know. + public static final int WARNING = 3; + /// Not set up yet (no device key registered, say). + public static final int OFF = 4; + + /// The hardening build hints declared in `codenameone_settings.properties`, + /// as `name=value`. The app cannot read its own build hints at run time, + /// so they are mirrored here; `SecurityStatusTest` fails when the two + /// disagree. + public static final String[] DECLARED_HINTS = { + "cn1.hardenLevel=aggressive", + "ios.detectJailbreak=true", + "ios.blockScreenshotsOnEnterBackground=true", + "android.playIntegrity=true", + }; + + /// One line of the Security Center. `name` and `detail` are English + /// bundle keys, translated where they are shown; `detail` is a pattern + /// whose `{0}`, `{1}`... are `values`, so a translation can put them where + /// its own grammar wants them. + public static final class Item { + public final String group; + public final String name; + public final int state; + public final String detail; + public final Object[] values; + + Item(String group, String name, int state, String detail, Object... values) { + this.group = group; + this.name = name; + this.state = state; + this.detail = detail; + this.values = values; + } + } + + private SecurityStatus() { + } + + public static List items() { + List out = new ArrayList(); + boolean sim = CN.isSimulator() || CN.isDesktop(); + boolean android = "and".equals(CN.getPlatformName()); + boolean ios = "ios".equals(CN.getPlatformName()); + String server = Session.serverUrl() == null ? AppConfig.serverUrl() : Session.serverUrl(); + boolean dev = AppConfig.isDevelopmentServer(server); + Guard guard = Guard.get(); + + // ------------------------------------------------------------ network + String g = "Network"; + out.add(dev + ? new Item(g, "No cleartext", WARNING, + "Development server {0} is exempt: plain HTTP to this machine only", server) + : refusals(guard) == 0 + ? new Item(g, "No cleartext", ACTIVE, "Every request is https; http is refused") + : new Item(g, "No cleartext", ACTIVE, + "Every request is https; http is refused ({0} refused)", refusals(guard))); + out.add(dev + ? new Item(g, "Host allow-list", WARNING, "Exempt while the development server is in use") + : new Item(g, "Host allow-list", ACTIVE, "Only {0}", + join(AppConfig.ALLOWED_HOSTS))); + out.add(pins(g, guard, dev)); + PinSet ota = AppShield.getPinSet(); + out.add(ota.isEmpty() + ? new Item(g, "Over-the-air pins", CLOUD_BUILD, + "Published by the attestation service; none in this build") + : new Item(g, "Over-the-air pins", ACTIVE, ota.isStale() + ? "{0} hosts, version {1} (refresh due)" : "{0} hosts, version {1}", + ota.hostCount(), ota.getVersion())); + out.add(AppShield.isProtected() + ? new Item(g, "App attestation token", ACTIVE, "AppShield engine {0}: {1}", + AppShield.getEngineName(), AppShield.getStatus()) + : new Item(g, "App attestation token", CLOUD_BUILD, + "AppShield needs the enterprise engine in a cloud build; inert here")); + + // ---------------------------------------------------------- the keys + g = "Keys and sign-in"; + out.add(storage(g, sim)); + out.add(appKey(g, sim, ios, android)); + out.add(Keys.pinEnabled() + ? new Item(g, "Unlock PIN", ACTIVE, Keys.pinFailures() > 0 + ? "A vault opened by the PIN (PBKDF2, {0} rounds, peppered from the OS store); {1} wrong tries in a row erase the keys. Wrong tries so far: {2}" + : "A vault opened by the PIN (PBKDF2, {0} rounds, peppered from the OS store); {1} wrong tries in a row erase the keys", + KdfProfile.current().getIterations(), Keys.PIN_ATTEMPTS, Keys.pinFailures()) + : new Item(g, "Unlock PIN", OFF, "Not set: Settings, Unlock PIN")); + out.add(new Item(g, "Refresh token", Keys.protectedByUser() ? ACTIVE : WARNING, + Keys.protectedByUser() + ? "Sealed under the app key: unreadable until the lock is opened" + : "Sealed under the app key, which opens without asking until a biometric or PIN is set up")); + out.add(DeviceKey.deviceId() != null + ? new Item(g, "Device signing key", ACTIVE, "P-256, registered as {0}; signs every payment", + DeviceKey.deviceId()) + : new Item(g, "Device signing key", OFF, + "Not registered: payments that need a signature are refused")); + out.add(new Item(g, "Idempotency keys", ACTIVE, + "Each money action carries one, so a retry never pays twice")); + out.add(new Item(g, "Second factor and passkeys", ACTIVE, Session.passkeysSupported() + ? "Authenticator codes and passkeys" : "Authenticator codes (no passkeys on this platform)")); + + // ------------------------------------------------------ app and device + g = "This device"; + out.add(new Item(g, "App lock", ACTIVE, Keys.biometricEnabled() + ? Keys.pinEnabled() ? "On start, on return and when idle: biometric, then PIN" + : "On start, on return and when idle: biometric" + : Keys.pinEnabled() ? "On start, on return and when idle: PIN" + : "On start, on return and when idle: the password")); + out.add(Lock.biometricsAvailable() + ? new Item(g, "Biometrics", Keys.biometricEnabled() ? ACTIVE : OFF, + Keys.biometricEnabled() ? "Unlock with a biometric is on" + : "Available; turn it on in Settings") + : new Item(g, "Biometrics", sim ? SIMULATOR : OFF, + "None enrolled: the PIN or the password unlocks instead")); + out.add(new Item(g, "Auto-lock", ACTIVE, "After {0}s idle or {1}s in the background", + AppConfig.IDLE_LOCK_MILLIS / 1000, AppConfig.BACKGROUND_LOCK_MILLIS / 1000)); + out.add(integrity(g, sim)); + out.add(DeviceIntegrity.isAttestationSupported() + ? new Item(g, "Platform attestation", ACTIVE, + android ? "Play Integrity" : "App Attest") + : new Item(g, "Platform attestation", CLOUD_BUILD, + "Play Integrity / App Attest need a device build")); + out.add(DeviceIntegrity.hasUntrustedAccessibilityService() + ? new Item(g, "Accessibility services", WARNING, "Can read the screen: {0}", + join(DeviceIntegrity.getEnabledAccessibilityServices())) + : new Item(g, "Accessibility services", ACTIVE, "None that can read the screen")); + out.add(android && DeviceIntegrity.getTapjackingPolicy() == TapjackingPolicy.BLOCK + ? new Item(g, "Tapjacking", ACTIVE, "Touches through overlays are dropped") + : new Item(g, "Tapjacking", SIMULATOR, "Policy set; only Android has overlays")); + out.add(sim + ? new Item(g, "Screenshot blocking", SIMULATOR, + "Set on sensitive screens; the simulator cannot block captures") + : new Item(g, "Screenshot blocking", ACTIVE, ios + ? "Sensitive screens, and the app switcher card" + : "Sensitive screens and the recent-apps card")); + out.add(new Item(g, "Clipboard clearing", ACTIVE, "Copied numbers are wiped after {0}s", + AppConfig.CLIPBOARD_CLEAR_MILLIS / 1000)); + + // ------------------------------------------------------------- build + g = "Build hardening"; + out.add(Hardening.isHardened() + ? new Item(g, "Code hardening", ACTIVE, "Level {0}, mapping {1}", Hardening.getLevel(), + Hardening.getMappingId()) + : new Item(g, "Code hardening", CLOUD_BUILD, + "Declared {0}; applied by the build server", hint("cn1.hardenLevel"))); + out.add(new Item(g, "Jailbreak exit (iOS)", ios && !sim ? ACTIVE : CLOUD_BUILD, + "ios.detectJailbreak={0}", hint("ios.detectJailbreak"))); + out.add(new Item(g, "App switcher blanking (iOS)", ios && !sim ? ACTIVE : CLOUD_BUILD, + "ios.blockScreenshotsOnEnterBackground={0}", + hint("ios.blockScreenshotsOnEnterBackground"))); + out.add(new Item(g, "Play Integrity (Android)", android && !sim ? ACTIVE : CLOUD_BUILD, + "android.playIntegrity={0}", hint("android.playIntegrity"))); + return out; + } + + /// The declared value of build hint `name`, or "unset". + public static String hint(String name) { + for (String line : DECLARED_HINTS) { + if (line.startsWith(name + "=")) { + return line.substring(name.length() + 1); + } + } + return "unset"; + } + + private static Item pins(String g, Guard guard, boolean dev) { + boolean placeholder = false; + for (String[] row : AppConfig.PINS) { + for (int iter = 1; iter < row.length; iter++) { + placeholder |= row[iter].startsWith("REPLACE"); + } + } + if (dev) { + return new Item(g, "Certificate pinning", WARNING, + "Not applied to the development server (it has no certificate)"); + } + if (placeholder) { + return new Item(g, "Certificate pinning", WARNING, + "Placeholder pins: put the server's SPKI digests in AppConfig.PINS"); + } + return guard == null || guard.pinFailureCount() == 0 + ? new Item(g, "Certificate pinning", ACTIVE, "{0} hosts pinned", AppConfig.PINS.length) + : new Item(g, "Certificate pinning", ACTIVE, "{0} hosts pinned, {1} mismatches refused", + AppConfig.PINS.length, guard.pinFailureCount()); + } + + private static Item storage(String g, boolean sim) { + ProtectionReport report = SecureStorage.getInstance().protection(); + if (report.provides(Protection.HARDWARE_BACKED)) { + return new Item(g, "Token and key storage", ACTIVE, "Hardware-backed keystore"); + } + if (report.provides(Protection.OS_PROTECTED)) { + return new Item(g, "Token and key storage", ACTIVE, "OS keystore"); + } + return new Item(g, "Token and key storage", sim ? SIMULATOR : WARNING, sim + ? "The simulator keeps them in a plain file" + : "No keystore on this device: tokens are not kept between runs"); + } + + /// Where the app key that seals the payment key and the refresh token is + /// held, said as it is on this platform: only iOS and Android bind a key + /// to a biometric in the OS. + private static Item appKey(String g, boolean sim, boolean ios, boolean android) { + String name = "App key"; + if (Keys.biometricEnabled()) { + if (sim) { + return new Item(g, name, SIMULATOR, "Biometric copy simulated: a file gated by the simulator's Biometric Simulation menu"); + } + if (ios) { + return new Item(g, name, ACTIVE, "Keychain item with biometric access control (current set): released only after Face ID or Touch ID"); + } + if (android) { + return new Item(g, name, ACTIVE, "Keystore key that requires user authentication: released only after a strong biometric"); + } + return new Item(g, name, ACTIVE, "Released by the platform after a biometric"); + } + if (Keys.pinEnabled()) { + return new Item(g, name, ACTIVE, "Opened only by the unlock PIN"); + } + return new Item(g, name, WARNING, ios || android || sim + ? "Opened without asking: set up a biometric or a PIN" + : "This platform has no biometric-bound store: set up a PIN"); + } + + private static Item integrity(String g, boolean sim) { + if (sim) { + return new Item(g, "Root and jailbreak checks", SIMULATOR, + "Run on a phone; the simulator reports nothing"); + } + if (DeviceIntegrity.isDeviceCompromised()) { + return new Item(g, "Root and jailbreak checks", WARNING, + "This device looks modified: {0}. The bank will not move money from it.", + join(DeviceIntegrity.getCompromiseReasons())); + } + return new Item(g, "Root and jailbreak checks", ACTIVE, "No signs of root or jailbreak"); + } + + private static int refusals(Guard guard) { + return guard == null ? 0 : guard.refusedCount(); + } + + private static String join(String[] values) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; values != null && iter < values.length; iter++) { + if (iter > 0) { + out.append(", "); + } + out.append(values[iter]); + } + return out.toString(); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/AccountForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/AccountForm.java new file mode 100644 index 00000000000..ad00ae82109 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/AccountForm.java @@ -0,0 +1,605 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Command; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.bastion.api.AccountDto; +import com.codenameone.examples.bastion.api.BalancePointDto; +import com.codenameone.examples.bastion.api.CashflowDayDto; +import com.codenameone.examples.bastion.api.CardDto; +import com.codenameone.examples.bastion.api.CashDto; +import com.codenameone.examples.bastion.api.PostingDto; +import com.codenameone.examples.bastion.api.ReceiptDto; +import com.codenameone.examples.bastion.money.Amounts; +import com.codenameone.examples.bastion.money.Assets; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import com.codenameone.examples.bastion.security.RequestSigner; +import java.util.List; + +/// One account: its balance, what it can do, and its history. +/// +/// The history is the account's side of each ledger entry -- a posting -- +/// newest first, as the server keeps them. Every amount is the posting's own +/// signed number of units; nothing is summed here, so what is shown is what +/// the ledger says, not what the app worked out. +public final class AccountForm { + private AccountForm() { + } + + public static void show(final String accountId, final Runnable back) { + Form form = Nav.form("Account", back); + final Container content = new Container(BoxLayout.y()); + form.add(content); + Ui.show(form, true); + load(accountId, content, back); + } + + private static void load(final String accountId, final Container content, final Runnable back) { + Ui.loading(content); + Api.banking().accounts(Net.to((List accounts) -> { + AccountDto account = null; + for (AccountDto one : accounts) { + if (one.id().equals(accountId)) { + account = one; + } + } + if (account == null) { + Ui.failed(content, "No such account", back); + return; + } + final AccountDto found = account; + Api.banking().history(accountId, Net.to( + (List postings) -> fill(content, found, postings, back), + (status, message) -> Ui.failed(content, message, + () -> load(accountId, content, back)))); + }, (status, message) -> Ui.failed(content, message, () -> load(accountId, content, back)))); + } + + private static void fill(final Container content, final AccountDto account, + List postings, final Runnable back) { + content.removeAll(); + // Coming back from a screen that may have moved money shows this form + // again, reloaded; the drill-downs only read, so they show it as it was. + final Form self = content.getComponentForm(); + final Runnable stay = () -> Ui.show(self, true); + final Runnable again = () -> { + stay.run(); + load(account.id(), content, back); + }; + Container hero = new Container(BoxLayout.y(), "BnHero"); + hero.add(new Label(account.name(), "BnHeroLabel")); + hero.add(new Label(Amounts.format(account.balance(), account.asset()), "BnHeroAmount")); + hero.add(new Label(HomeForm.kindName(account.kind()) + " - " + account.asset() + + (account.frozen() ? " - " + Lang.tr("Frozen") : ""), "BnHeroNote")); + content.add(hero); + + Button move = Ui.primary("Move money"); + move.addActionListener(evt -> TransferForm.show(account.id(), false, again)); + if (Assets.isCrypto(account.asset())) { + Button send = Ui.secondary("Send"); + send.addActionListener(evt -> WithdrawForm.show(account.id(), again)); + content.add(Ui.across(move, send)); + } else { + Button deposit = Ui.secondary("Add money"); + deposit.addActionListener(evt -> cash(account, true, again)); + Button withdraw = Ui.secondary("Withdraw"); + withdraw.addActionListener(evt -> cash(account, false, again)); + content.add(Ui.across(move, deposit, withdraw)); + } + if ("CHECKING".equals(account.kind())) { + Button card = Ui.secondary("Order a card"); + // Signed: a card is one more way for money to leave the account. + card.addActionListener(evt -> DeviceSetup.withDevice(retry -> Api.banking() + .issueCard(account.id(), RequestSigner.sign("POST", "/api/accounts/" + + account.id() + "/card", null, null), + Net.to((CardDto issued) -> CardsForm.show(again), retry)), + (status, message) -> Dialog.show(Lang.tr("No card"), Lang.tr(message), + Lang.tr("OK"), null))); + content.add(card); + } + + final Container charts = new Container(BoxLayout.y()); + content.add(charts); + charts(charts, account); + + Container head = new Container(new com.codename1.ui.layouts.BorderLayout()); + head.add(com.codename1.ui.layouts.BorderLayout.CENTER, Ui.section("History")); + if (!postings.isEmpty()) { + Button statement = new Button(Lang.tr("Statement"), "BnLink"); + statement.addActionListener(evt -> statement(account, postings, stay)); + head.add(com.codename1.ui.layouts.BorderLayout.EAST, statement); + } + content.add(head); + if (postings.isEmpty()) { + content.add(Ui.empty("Nothing has moved in this account yet.")); + } + long balance = account.balance(); + for (final PostingDto posting : postings) { + boolean in = posting.amount() > 0; + String amount = (in ? "+" : "") + Amounts.format(posting.amount(), posting.asset()); + final long after = balance; + balance -= posting.amount(); + content.add(Ui.row(Lang.tr(in ? "Money in" : "Money out"), Ui.when(posting.createdAt()), + amount, in ? "BnAmountIn" : "BnAmountOut", + () -> detail(account, posting, after, stay))); + } + content.revalidate(); + } + + /// The balance over 30 days and the money in and out each day, drawn + /// once their answers arrive; the screen does not wait for them. + private static void charts(final Container box, final AccountDto account) { + final double scale = Amounts.pow10(Assets.decimals(account.asset())); + Api.insights().balanceHistory(account.id(), "30", Net.to((List points) -> { + if (points.size() < 2) { + return; + } + long[] days = new long[points.size()]; + double[] values = new double[days.length]; + for (int iter = 0; iter < days.length; iter++) { + days[iter] = points.get(iter).day(); + values[iter] = points.get(iter).balance() / scale; + } + box.addComponent(0, Ui.section("Balance, 30 days")); + box.addComponent(1, Charts.line("account-balance-chart", days, values, + Assets.REPORTING.equals(account.asset()) ? "$" : "")); + refresh(box); + }, (status, message) -> { })); + Api.insights().cashflow(account.asset(), "14", Net.to((List flows) -> { + if (flows.isEmpty()) { + return; + } + long[] days = new long[flows.size()]; + double[] in = new double[days.length]; + double[] out = new double[days.length]; + for (int iter = 0; iter < days.length; iter++) { + days[iter] = flows.get(iter).day(); + in[iter] = flows.get(iter).inflow() / scale; + out[iter] = Math.abs(flows.get(iter).outflow()) / scale; + } + box.add(Ui.section("Money in and out, 14 days")); + box.add(Charts.inOut("cashflow-chart", days, in, out)); + refresh(box); + }, (status, message) -> { })); + } + + private static void refresh(Container box) { + if (box.getComponentForm() != null) { + box.getComponentForm().revalidate(); + } + } + + /// One line of the history in full: the account's side of a ledger entry, + /// with the balance it left. + private static void detail(final AccountDto account, PostingDto posting, long after, + final Runnable back) { + Form form = Nav.form("Transaction", back); + boolean in = posting.amount() > 0; + Container hero = new Container(BoxLayout.y(), "BnHero"); + hero.add(new Label(Lang.tr(in ? "Money in" : "Money out"), "BnHeroLabel")); + hero.add(new Label((in ? "+" : "") + Amounts.format(posting.amount(), posting.asset()), + "BnHeroAmount")); + hero.add(new Label(Ui.when(posting.createdAt()), "BnHeroNote")); + form.add(hero); + form.add(Ui.row(describe(posting), category(posting.kind()), null, null, null)); + form.add(Ui.row(Lang.tr("Account"), account.name(), account.asset(), "BnRowValueMuted", + null)); + form.add(Ui.row(Lang.tr("Side"), Lang.tr(in ? "Credit to your account" + : "Debit from your account"), in ? "Cr" : "Dr", "BnRowValueMono", null)); + form.add(Ui.row(Lang.tr("Balance after"), null, Amounts.format(after, posting.asset()), + "BnRowValue", null)); + form.add(Ui.copyable(Lang.tr("Ledger entry"), posting.entryId(), false)); + form.add(Ui.copyable(Lang.tr("Posting"), posting.id(), false)); + form.add(Ui.copyable(Lang.tr("Account ID"), account.id(), true)); + form.add(Ui.muted(Lang.tr("The other side of this entry is in the bank's books; every " + + "entry's debits equal its credits."))); + final boolean credit = in; + Button print = Printout.button(() -> { + Printout p = new Printout(Lang.tr("Transaction"), System.currentTimeMillis()) + .fact(Lang.tr("Account"), account.name() + " (" + account.asset() + ")") + .fact(Lang.tr("Ledger entry"), posting.entryId()) + .fact(Lang.tr("Posting"), posting.id()); + p.section(null, new String[] {Lang.tr("Date"), Lang.tr("Description"), + Lang.tr("Category"), Lang.tr("Money in"), Lang.tr("Money out")}, + new int[] {3, 5, 3, 3, 3}, new boolean[] {false, false, false, true, true}) + .row(Ui.when(posting.createdAt()), describe(posting), + category(posting.kind()), + credit ? Amounts.format(posting.amount(), posting.asset()) : "", + credit ? "" : Amounts.format(-posting.amount(), posting.asset())); + return p.total(Lang.tr("Balance after"), Amounts.format(after, posting.asset())); + }); + if (print != null) { + form.add(print); + } + Ui.show(form, true); + } + + /// The statement's columns, by key; the header of each is its English + /// name in [#COLUMN_NAMES], and [#COLUMN_WEIGHTS] its share of the width. + static final String[] COLUMNS = {"date", "description", "category", "reference", "in", "out", + "amount", "balance"}; + private static final String[] COLUMN_NAMES = {"Date", "Description", "Category", "Reference", + "Money in", "Money out", "Amount", "Balance"}; + private static final int[] COLUMN_WEIGHTS = {3, 5, 3, 3, 3, 3, 3, 3}; + private static final boolean[] COLUMN_NUMERIC = {false, false, false, false, true, true, true, + true}; + /// What a phone in portrait shows until the user picks: the date, what + /// the line was and its signed amount. A tap on a line shows the rest. + static final String NARROW_DEFAULT = "date,description,amount"; + /// What a wider window shows: everything but the signed amount, which + /// the money in and money out columns already say. + static final String WIDE_DEFAULT = "date,description,category,reference,in,out,balance"; + + /// A window this wide (in mm) has room for the wide set of columns. + private static final float WIDE_MM = 120f; + + /// A statement: a header with the account, the period and its totals, + /// then every posting oldest first with the running balance, in the + /// columns the window has room for -- or the ones the user picked. + private static void statement(final AccountDto account, final List postings, + final Runnable back) { + final Form form = Nav.form("Statement", back); + final Runnable stay = () -> Ui.show(form, true); + long opening = account.balance(); + long in = 0; + long out = 0; + for (PostingDto posting : postings) { + opening -= posting.amount(); + if (posting.amount() > 0) { + in += posting.amount(); + } else { + out -= posting.amount(); + } + } + final String asset = account.asset(); + Container head = new Container(BoxLayout.y(), "BnStatementHead"); + head.add(new Label(account.name(), "BnRowTitle")); + String period = postings.isEmpty() ? "" : Lang.tr("{0} to {1}", + Ui.day(postings.get(postings.size() - 1).createdAt()), + Ui.day(postings.get(0).createdAt())); + Ui.addDetail(head, HomeForm.kindName(account.kind()) + " - " + asset + + (period.length() > 0 ? " - " + period : "")); + boolean wide = CN.getDisplayWidth() >= CN.convertToPixels(WIDE_MM); + Container totals = new Container(new com.codename1.ui.layouts.GridLayout(wide ? 1 : 2, + wide ? 4 : 2)); + totals.add(fact("Opening balance", Amounts.format(opening, asset))); + totals.add(fact("Closing balance", Amounts.format(account.balance(), asset))); + totals.add(fact("Money in", Amounts.format(in, asset))); + totals.add(fact("Money out", Amounts.format(out, asset))); + head.add(totals); + form.add(head); + final Container holder = new Container(BoxLayout.y()); + form.add(holder); + final long openingBalance = opening; + final Runnable fill = () -> { + holder.removeAll(); + holder.add(statementTable(account, postings, openingBalance, stay)); + form.revalidate(); + }; + fill.run(); + if (Printout.supported()) { + final long[] sums = {openingBalance, in, out}; + final String when = period; + form.getToolbar().addMaterialCommandToRightBar("", FontImage.MATERIAL_PRINT, + evt -> Printout.print(statementPrintout(account, postings, sums[0], sums[1], + sums[2], when))); + } + form.getToolbar().addMaterialCommandToRightBar("", FontImage.MATERIAL_VIEW_COLUMN, + evt -> { + if (chooseColumns()) { + fill.run(); + } + }); + // Turning a phone, or resizing a desktop window, changes what fits. + final boolean[] wasWide = {wide}; + form.addSizeChangedListener(evt -> { + boolean now = CN.getDisplayWidth() >= CN.convertToPixels(WIDE_MM); + if (now != wasWide[0]) { + wasWide[0] = now; + fill.run(); + } + }); + Ui.show(form, true); + } + + /// The statement on paper: every column, whatever the window shows -- + /// paper has the room, and a printed statement is read in full. + static Printout statementPrintout(AccountDto account, List postings, + long opening, long in, long out, String period) { + String asset = account.asset(); + Printout p = new Printout(Lang.tr("Statement"), System.currentTimeMillis()) + .fact(Lang.tr("Account"), account.name() + " - " + + HomeForm.kindName(account.kind()) + " - " + asset) + .fact(Lang.tr("Account ID"), account.id()) + .fact(Lang.tr("Period"), period); + String[] headers = new String[COLUMNS.length - 1]; + int[] weights = new int[headers.length]; + boolean[] numeric = new boolean[headers.length]; + int at = 0; + for (int iter = 0; iter < COLUMNS.length; iter++) { + if (!"amount".equals(COLUMNS[iter])) { + headers[at] = Lang.tr(COLUMN_NAMES[iter]); + weights[at] = COLUMN_WEIGHTS[iter]; + numeric[at++] = COLUMN_NUMERIC[iter]; + } + } + Printout.Section table = p.section(null, headers, weights, numeric); + long running = opening; + for (int iter = postings.size() - 1; iter >= 0; iter--) { + PostingDto posting = postings.get(iter); + running += posting.amount(); + boolean credit = posting.amount() > 0; + table.row(Ui.when(posting.createdAt()), describe(posting), category(posting.kind()), + shortReference(posting.entryId()), + credit ? Amounts.format(posting.amount(), asset) : "", + credit ? "" : Amounts.format(-posting.amount(), asset), + Amounts.format(running, asset)); + } + if (postings.isEmpty()) { + table.note(Lang.tr("No movements in this period.")); + } + return p.total(Lang.tr("Opening balance"), Amounts.format(opening, asset)) + .total(Lang.tr("Money in"), Amounts.format(in, asset)) + .total(Lang.tr("Money out"), Amounts.format(out, asset)) + .total(Lang.tr("Closing balance"), Amounts.format(account.balance(), asset)); + } + + private static Container fact(String label, String value) { + Container fact = new Container(BoxLayout.y(), "BnFact"); + com.codename1.components.SpanLabel name = new com.codename1.components.SpanLabel( + Lang.tr(label)); + name.setTextUIID("BnFactLabel"); + fact.add(name); + fact.add(new Label(value, "BnFactValue")); + return fact; + } + + /// The preference that holds the columns picked for this width of window. + private static String columnsKey() { + return CN.getDisplayWidth() >= CN.convertToPixels(WIDE_MM) + ? "bastion.statement.columns.wide" : "bastion.statement.columns.narrow"; + } + + /// The column keys to show, in table order. + static String[] chosenColumns() { + boolean wide = CN.getDisplayWidth() >= CN.convertToPixels(WIDE_MM); + String saved = com.codename1.io.Preferences.get(columnsKey(), + wide ? WIDE_DEFAULT : NARROW_DEFAULT); + return pickColumns(saved); + } + + /// The known keys in `csv`, in the table's order; the date always. + static String[] pickColumns(String csv) { + String list = "," + (csv == null ? "" : csv) + ","; + int count = 0; + boolean[] on = new boolean[COLUMNS.length]; + for (int iter = 0; iter < COLUMNS.length; iter++) { + on[iter] = iter == 0 || list.indexOf("," + COLUMNS[iter] + ",") >= 0; + if (on[iter]) { + count++; + } + } + String[] out = new String[count]; + int at = 0; + for (int iter = 0; iter < COLUMNS.length; iter++) { + if (on[iter]) { + out[at++] = COLUMNS[iter]; + } + } + return out; + } + + /// Asks which columns to show; answers whether the choice changed. + private static boolean chooseColumns() { + String[] current = chosenColumns(); + Container box = new Container(BoxLayout.y()); + com.codename1.ui.CheckBox[] boxes = new com.codename1.ui.CheckBox[COLUMNS.length]; + for (int iter = 0; iter < COLUMNS.length; iter++) { + boxes[iter] = new com.codename1.ui.CheckBox(Lang.tr(COLUMN_NAMES[iter])); + boxes[iter].setUIID("BnCheck"); + boxes[iter].setSelected(Ui.indexOf(current, COLUMNS[iter]) >= 0); + // The date says which line is which; it is always there. + boxes[iter].setEnabled(iter != 0); + box.add(boxes[iter]); + } + Command ok = new Command(Lang.tr("Show")); + if (Dialog.show(Lang.tr("Columns"), box, ok, new Command(Lang.tr("Cancel"))) != ok) { + return false; + } + StringBuilder csv = new StringBuilder(); + for (int iter = 0; iter < COLUMNS.length; iter++) { + if (boxes[iter].isSelected() || iter == 0) { + csv.append(csv.length() == 0 ? "" : ",").append(COLUMNS[iter]); + } + } + com.codename1.io.Preferences.set(columnsKey(), csv.toString()); + return true; + } + + private static Component statementTable(final AccountDto account, List postings, + long opening, final Runnable stay) { + String asset = account.asset(); + String[] keys = chosenColumns(); + String[] headers = new String[keys.length]; + int[] weights = new int[keys.length]; + boolean[] numeric = new boolean[keys.length]; + int weight = 0; + for (int iter = 0; iter < keys.length; iter++) { + int column = Ui.indexOf(COLUMNS, keys[iter]); + headers[iter] = COLUMN_NAMES[column]; + weights[iter] = COLUMN_WEIGHTS[column]; + numeric[iter] = COLUMN_NUMERIC[column]; + weight += weights[iter]; + } + // Up to the width of the narrow default the columns are fitted to the + // window; more than that scrolls sideways rather than squeeze. + final boolean fitted = weight <= 11; + Table table = new Table(headers, weights, numeric, fitted); + long running = opening; + for (int iter = postings.size() - 1; iter >= 0; iter--) { + final PostingDto posting = postings.get(iter); + running += posting.amount(); + final long after = running; + boolean credit = posting.amount() > 0; + String[] cells = new String[keys.length]; + String[] styles = new String[keys.length]; + for (int c = 0; c < keys.length; c++) { + String key = keys[c]; + if ("date".equals(key)) { + // A fitted phone column has room for the day; the time + // is on the line's own screen, a tap away. + cells[c] = fitted ? Ui.day(posting.createdAt()) : Ui.when(posting.createdAt()); + } else if ("description".equals(key)) { + cells[c] = describe(posting); + } else if ("category".equals(key)) { + cells[c] = category(posting.kind()); + } else if ("reference".equals(key)) { + cells[c] = shortReference(posting.entryId()); + } else if ("in".equals(key)) { + cells[c] = credit ? Amounts.format(posting.amount(), asset) : ""; + } else if ("out".equals(key)) { + cells[c] = credit ? "" : Amounts.format(-posting.amount(), asset); + styles[c] = "BnCellNumOut"; + } else if ("amount".equals(key)) { + cells[c] = (credit ? "+" : "") + Amounts.format(posting.amount(), asset); + styles[c] = credit ? null : "BnCellNumOut"; + } else { + cells[c] = Amounts.format(after, asset); + } + } + // Every line opens in full: on a phone that is how the columns + // left out are read. + table.add(cells, "BnTableRow", styles, () -> detail(account, posting, after, stay)); + } + return table.component(); + } + + /// What a line was: the entry's description (a merchant, the sender's + /// note), else what kind of movement it was. + static String describe(PostingDto posting) { + String memo = posting.memo(); + if (memo != null && memo.length() > 0) { + return ReceiptForm.memo(memo); + } + return category(posting.kind()); + } + + /// The ledger's kind of an entry, in words. + static String category(String kind) { + if ("CARD".equals(kind)) { + return Lang.tr("Card"); + } + if ("TRANSFER".equals(kind) || "PAYMENT".equals(kind)) { + return Lang.tr("Transfer"); + } + if ("DEPOSIT".equals(kind) || "CAPITAL".equals(kind)) { + return Lang.tr("Deposit"); + } + if ("WITHDRAWAL".equals(kind)) { + return Lang.tr("Cash withdrawal"); + } + if ("CRYPTO_OUT".equals(kind)) { + return Lang.tr("Crypto withdrawal"); + } + if ("TRADE".equals(kind) || "ORDER_HOLD".equals(kind) || "ORDER_RELEASE".equals(kind)) { + return Lang.tr("Trade"); + } + return Lang.tr("Other"); + } + + /// A reference short enough for a column: its first eight characters, + /// which a support agent can search by. The full one is on the line's + /// own screen. + static String shortReference(String entryId) { + if (entryId == null) { + return ""; + } + return entryId.length() <= 8 ? entryId : entryId.substring(0, 8); + } + + /// Cash in or out at the counter -- the sample's stand-in for a card top-up + /// or an ATM. Withdrawing is money leaving the customer, so it is signed; + /// a deposit is not. + private static void cash(final AccountDto account, final boolean deposit, final Runnable done) { + final TextField amount = Ui.field("Amount", TextField.DECIMAL); + Command go = new Command(Lang.tr(deposit ? "Add" : "Withdraw")); + if (Dialog.show(Lang.tr(deposit ? "Add money" : "Withdraw cash"), amount, go, + new Command(Lang.tr("Cancel"))) != go) { + return; + } + long units = Amounts.parse(amount.getText(), account.asset()); + if (units <= 0) { + Dialog.show(Lang.tr("Amount"), Ui.amountHint(), Lang.tr("OK"), null); + return; + } + // A code, not English: the memo is stored and may be read back in + // another language ([ReceiptForm#memo]). + final CashDto cash = new CashDto(units, deposit ? ReceiptForm.MEMO_DEPOSIT + : ReceiptForm.MEMO_WITHDRAWAL); + // One key for this action, kept across the retry after registering + // AND across a second dialog for the same amount after a failure whose + // answer was lost -- as TransferForm does -- so trying again cannot + // move the money twice. A success forgets it: the next one is new. + final String key = keyFor(account.id() + (deposit ? "|in|" : "|out|") + units); + DeviceSetup.withDevice(failed -> { + Net.Ok ok = receipt -> { + forgetKey(); + done.run(); + }; + if (deposit) { + Api.banking().deposit(account.id(), key, cash, Net.to(ok, failed)); + } else { + String target = "/api/accounts/" + account.id() + "/withdraw"; + Api.banking().withdraw(account.id(), key, RequestSigner.sign("POST", target, key, + cash), + cash, Net.to(ok, failed)); + } + }, (status, message) -> Dialog.show(Lang.tr("Not done"), Lang.tr(message), + Lang.tr("OK"), null)); + } + + private static String triedAction; + private static String triedKey; + + /// The idempotency key for `action` (account, direction and amount): the + /// one the last unfinished try of the same action used, else a new one. + static String keyFor(String action) { + if (triedKey == null || !action.equals(triedAction)) { + triedAction = action; + triedKey = RequestSigner.idempotencyKey(); + } + return triedKey; + } + + static void forgetKey() { + triedAction = null; + triedKey = null; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/CardFace.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/CardFace.java new file mode 100644 index 00000000000..190c250dd31 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/CardFace.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Font; +import com.codename1.ui.FontImage; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import java.util.Date; + +/// A payment card drawn as one: the ID-1 shape of a real card (85.60 by +/// 53.98 mm, about 1.586 to 1) with rounded corners, a gradient, the chip, +/// the brand, the number in groups of four, the cardholder and the expiry. +/// A frozen card is drawn greyed with a frost mark across it. +/// +/// The colours and type come from styles (`BnCardFace`, `BnCardFaceEnd`, +/// `BnCardChip`, `BnCardBrand`, `BnCardNumber`, `BnCardCaption`, +/// `BnCardName`), so the theme re-skins it. The face is never mirrored in a +/// right-to-left language: a card is a physical object and reads the same +/// way in every country. +public final class CardFace extends Component { + /// Width over height of an ID-1 card. + static final float ASPECT = 85.60f / 53.98f; + /// A card on screen is never wider than this, in mm, so a tablet or a + /// desktop window does not draw a card the size of a placemat. + private static final float MAX_WIDTH_MM = 95f; + + private final String last4; + private final String holder; + private final String expiry; + private final boolean frozen; + + /// @param last4 the last four digits, all the app ever has + /// @param holder the name printed on the card + /// @param expiry the expiry, MM/YY + /// @param frozen whether to draw it frozen + public CardFace(String last4, String holder, String expiry, boolean frozen) { + this.last4 = last4; + this.holder = holder; + this.expiry = expiry; + this.frozen = frozen; + setUIID("BnCardFace"); + setFocusable(false); + } + + /// The expiry printed on a card issued at `issuedAt`: three years on, + /// the way a virtual card is commonly issued. + public static String expiryOf(long issuedAt) { + long at = issuedAt <= 0 ? System.currentTimeMillis() : issuedAt; + return new SimpleDateFormat("MM/yy").format(new Date(at + 3L * 365 * 24 * 3600 * 1000)); + } + + /// The number as printed: three masked groups and the last four. + public static String maskedNumber(String last4) { + return "\u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 " + + (last4 == null ? "" : last4); + } + + @Override + protected Dimension calcPreferredSize() { + int room = CN.getDisplayWidth() - CN.convertToPixels(8f); + Component parent = getParent(); + if (parent != null && parent.getWidth() > 0) { + room = parent.getWidth() - parent.getStyle().getHorizontalPadding(); + } + int width = Math.min(room, CN.convertToPixels(MAX_WIDTH_MM)); + return new Dimension(width, Math.round(width / ASPECT)); + } + + @Override + public void paint(Graphics g) { + int w = Math.min(getWidth(), Math.round(getHeight() * ASPECT)); + int h = Math.round(w / ASPECT); + int x = getX() + (getWidth() - w) / 2; + int y = getY(); + boolean anti = g.isAntiAliased(); + g.setAntiAliased(true); + int radius = Math.max(4, w / 22); + int from = color(frozen ? "BnCardFaceFrozen" : "BnCardFace"); + int to = color(frozen ? "BnCardFaceFrozenEnd" : "BnCardFaceEnd"); + // A diagonal gradient, a row at a time, each row cut in at the + // rounded corners. + for (int row = 0; row < h; row++) { + int inset = 0; + int dy = row < radius ? radius - row : row >= h - radius ? row - (h - radius - 1) : 0; + if (dy > 0) { + inset = radius - (int) Math.round(Math.sqrt(radius * radius - dy * dy)); + } + int left = mix(from, to, (float) row / h * 0.6f); + int right = mix(from, to, 0.4f + (float) row / h * 0.6f); + int span = w - 2 * inset; + if (span > 0) { + g.fillLinearGradient(left, right, x + inset, y + row, span, 1, true); + } + } + int pad = Math.round(w * 0.07f); + // The brand: the shield and the name, top left. + Style brand = UIManager.getInstance().getComponentStyle("BnCardBrand"); + Font brandFont = brand.getFont(); + g.setColor(brand.getFgColor()); + g.setFont(brandFont); + Font icon = FontImage.getMaterialDesignFont().derive(brandFont.getHeight(), Font.STYLE_PLAIN); + g.setFont(icon); + g.drawString(String.valueOf(FontImage.MATERIAL_SHIELD), x + pad, y + pad); + int iconWidth = icon.charWidth(FontImage.MATERIAL_SHIELD); + g.setFont(brandFont); + g.drawString("Bastion", x + pad + iconWidth + pad / 4, y + pad); + // What kind of card, top right, with the contactless mark under it. + Style caption = UIManager.getInstance().getComponentStyle("BnCardCaption"); + Font captionFont = caption.getFont(); + g.setFont(captionFont); + g.setColor(caption.getFgColor()); + String kind = Lang.tr("Virtual debit"); + g.drawString(kind, x + w - pad - captionFont.stringWidth(kind), + y + pad + (brandFont.getHeight() - captionFont.getHeight()) / 2); + // The chip, left, a third of the way down. + int chipW = Math.round(w * 0.13f); + int chipH = Math.round(chipW * 0.76f); + int chipX = x + pad; + int chipY = y + Math.round(h * 0.34f); + int chip = color("BnCardChip"); + g.setColor(chip); + g.fillRoundRect(chipX, chipY, chipW, chipH, chipH / 3, chipH / 3); + g.setColor(mix(chip, 0x000000, 0.35f)); + g.drawRoundRect(chipX, chipY, chipW, chipH, chipH / 3, chipH / 3); + g.drawLine(chipX + chipW / 3, chipY, chipX + chipW / 3, chipY + chipH); + g.drawLine(chipX + 2 * chipW / 3, chipY, chipX + 2 * chipW / 3, chipY + chipH); + g.drawLine(chipX, chipY + chipH / 2, chipX + chipW / 3, chipY + chipH / 2); + g.drawLine(chipX + 2 * chipW / 3, chipY + chipH / 2, chipX + chipW, chipY + chipH / 2); + Font contactless = FontImage.getMaterialDesignFont().derive(chipH, Font.STYLE_PLAIN); + g.setFont(contactless); + g.setColor(caption.getFgColor()); + g.drawString(String.valueOf(FontImage.MATERIAL_CONTACTLESS), + chipX + chipW + pad / 2, chipY); + // The number, in groups of four, under the chip. + Style number = UIManager.getInstance().getComponentStyle("BnCardNumber"); + Font numberFont = number.getFont(); + g.setFont(numberFont); + g.setColor(number.getFgColor()); + g.drawString(maskedNumber(last4), x + pad, chipY + chipH + Math.round(h * 0.07f)); + // The cardholder and the expiry along the bottom, each under a + // small caption. + Style name = UIManager.getInstance().getComponentStyle("BnCardName"); + Font nameFont = name.getFont(); + int bottom = y + h - pad; + int valueY = bottom - nameFont.getHeight(); + int captionY = valueY - captionFont.getHeight(); + String expires = Lang.tr("Expires"); + int expiryWidth = Math.max(nameFont.stringWidth(expiry), captionFont.stringWidth(expires)); + int expiryX = x + w - pad - expiryWidth; + g.setFont(captionFont); + g.setColor(caption.getFgColor()); + g.drawString(Lang.tr("Cardholder"), x + pad, captionY); + g.drawString(expires, expiryX, captionY); + g.setFont(nameFont); + g.setColor(name.getFgColor()); + g.drawString(fit(holder, nameFont, expiryX - x - 2 * pad), x + pad, valueY); + g.drawString(expiry, expiryX, valueY); + if (frozen) { + // The frost: a veil over the face and the mark in its middle. + int alpha = g.getAlpha(); + g.setColor(0xffffff); + g.setAlpha(70); + g.fillRoundRect(x, y, w, h, 2 * radius, 2 * radius); + g.setAlpha(alpha); + String label = Lang.tr("Frozen"); + Font mark = FontImage.getMaterialDesignFont().derive(h / 4, Font.STYLE_PLAIN); + int markW = mark.charWidth(FontImage.MATERIAL_AC_UNIT); + int labelW = brandFont.stringWidth(label); + int total = markW + pad / 3 + labelW; + int left = x + (w - total) / 2; + int middle = y + h / 2; + g.setColor(0xffffff); + g.setFont(mark); + g.drawString(String.valueOf(FontImage.MATERIAL_AC_UNIT), left, + middle - mark.getHeight() / 2); + g.setFont(brandFont); + g.drawString(label, left + markW + pad / 3, middle - brandFont.getHeight() / 2); + } + g.setAntiAliased(anti); + } + + /// `text`, cut with an ellipsis only when it is wider than `width` -- + /// a name longer than the card, which a real card would print short too. + private static String fit(String text, Font font, int width) { + if (text == null) { + return ""; + } + if (font.stringWidth(text) <= width) { + return text; + } + String cut = text; + while (cut.length() > 1 && font.stringWidth(cut + "\u2026") > width) { + cut = cut.substring(0, cut.length() - 1); + } + return cut + "\u2026"; + } + + private static int color(String uiid) { + return UIManager.getInstance().getComponentStyle(uiid).getFgColor(); + } + + private static int mix(int a, int b, float t) { + float u = Math.max(0f, Math.min(1f, t)); + int r = Math.round(((a >> 16) & 0xff) * (1 - u) + ((b >> 16) & 0xff) * u); + int gr = Math.round(((a >> 8) & 0xff) * (1 - u) + ((b >> 8) & 0xff) * u); + int bl = Math.round((a & 0xff) * (1 - u) + (b & 0xff) * u); + return (r << 16) | (gr << 8) | bl; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/CardsForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/CardsForm.java new file mode 100644 index 00000000000..2750f80d22e --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/CardsForm.java @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Command; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.bastion.api.AccountDto; +import com.codenameone.examples.bastion.api.CardDto; +import com.codenameone.examples.bastion.api.CardLimitDto; +import com.codenameone.examples.bastion.api.PostingDto; +import com.codenameone.examples.bastion.api.UserDto; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.security.RequestSigner; +import com.codenameone.examples.bastion.money.Amounts; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import java.util.List; + +/// The cards: freeze one the moment it is missing, and set what it may spend +/// in a day. +/// +/// Freezing is instant and needs no signature -- the point is that it can be +/// done in a hurry -- while the daily limit bounds what a stolen card number +/// costs before anyone notices. Only the last four digits ever reach the +/// app. +public final class CardsForm { + private CardsForm() { + } + + public static void show(final Runnable back) { + Form form = Nav.form("Cards", back); + final Container content = new Container(BoxLayout.y()); + form.add(content); + Ui.show(form, true); + load(content); + } + + private static void load(final Container content) { + Ui.loading(content); + Api.banking().cards(Net.to((List cards) -> + Api.banking().accounts(Net.to((List accounts) -> + fill(content, cards, accounts), + (status, message) -> Ui.failed(content, message, () -> load(content)))), + (status, message) -> Ui.failed(content, message, () -> load(content)))); + } + + private static void fill(final Container content, List cards, + List accounts) { + content.removeAll(); + if (cards.isEmpty()) { + content.add(Ui.empty("No cards yet. Order one from a checking account.")); + } + UserDto user = Session.user(); + String holder = user == null ? "" : user.displayName() == null ? user.username() + : user.displayName(); + for (final CardDto card : cards) { + final AccountDto account = accountOf(accounts, card.accountId()); + final String asset = account == null ? "USD" : account.asset(); + Container face = new Container(new FlowLayout(Component.CENTER)); + face.add(new CardFace(card.last4(), holder, CardFace.expiryOf(card.createdAt()), + card.frozen())); + content.add(face); + + final Button freeze = Ui.secondary(card.frozen() ? "Unfreeze" : "Freeze"); + freeze.addActionListener(evt -> { + freeze.setEnabled(false); + Net.Failed failed = (status, message) -> { + freeze.setEnabled(true); + Dialog.show(Lang.tr("Not changed"), Lang.tr(message), Lang.tr("OK"), null); + }; + if (card.frozen()) { + DeviceSetup.withDevice(retry -> Api.banking().unfreezeCard(card.id(), + RequestSigner.sign("POST", "/api/cards/" + card.id() + "/unfreeze", null, + null), Net.to((CardDto c) -> load(content), retry)), failed); + } else { + Api.banking().freezeCard(card.id(), Net.to((CardDto c) -> load(content), failed)); + } + }); + Button limit = Ui.secondary("Daily limit"); + limit.addActionListener(evt -> limit(content, card, asset)); + content.add(Ui.across(freeze, limit)); + + // The card's details, then what it spent: both from the account's + // history, which the server keeps per entry kind. + content.add(Ui.section("Card details")); + content.add(Ui.row(Lang.tr("Status"), null, Lang.tr(card.frozen() ? "Frozen" : "Active"), + card.frozen() ? "BnRowValueMuted" : "BnAmountIn", null)); + content.add(Ui.row(Lang.tr("Type"), null, Lang.tr("Virtual debit"), null, null)); + content.add(Ui.row(Lang.tr("Account"), null, account == null ? "" : account.name(), + null, null)); + content.add(Ui.row(Lang.tr("Expires"), null, CardFace.expiryOf(card.createdAt()), null, + null)); + content.add(Ui.copyable(Lang.tr("Card ID"), card.id(), true)); + final Container used = new Container(BoxLayout.y(), "BnRow"); + content.add(used); + final Container recent = new Container(BoxLayout.y()); + content.add(recent); + showUse(used, card, asset, 0); + Api.banking().history(card.accountId(), Net.to((List postings) -> + payments(used, recent, card, asset, postings), + (status, message) -> { })); + } + content.revalidate(); + } + + /// The daily limit and how much of it today's payments have used. + private static void showUse(Container used, CardDto card, String asset, long spent) { + used.removeAll(); + Container top = new Container(new BorderLayout()); + top.add(BorderLayout.CENTER, new Label(Lang.tr("Daily limit"), "BnRowTitle")); + top.add(BorderLayout.EAST, new Label(Amounts.format(card.dailyLimit(), asset), + "BnRowValue")); + used.add(top); + long limit = Math.max(1L, card.dailyLimit()); + used.add(new Meter((float) Math.min(spent, limit) / limit)); + Ui.addDetail(used, Lang.tr("{0} used today, {1} left", + Amounts.format(spent, asset), + Amounts.format(Math.max(0L, card.dailyLimit() - spent), asset))); + } + + /// The card's recent payments: the account's entries of kind CARD. + private static void payments(Container used, Container recent, CardDto card, String asset, + List postings) { + long today = startOfToday(); + long spent = 0; + int shown = 0; + recent.removeAll(); + recent.add(Ui.section("Recent card payments")); + for (PostingDto posting : postings) { + if (!"CARD".equals(posting.kind())) { + continue; + } + if (posting.createdAt() >= today && posting.amount() < 0) { + spent -= posting.amount(); + } + if (shown < RECENT) { + boolean in = posting.amount() > 0; + recent.add(Ui.row(AccountForm.describe(posting), Ui.when(posting.createdAt()), + (in ? "+" : "") + Amounts.format(posting.amount(), asset), + in ? "BnAmountIn" : "BnAmountOut", null)); + shown++; + } + } + if (shown == 0) { + recent.add(Ui.empty("This card has not been used yet.")); + } + showUse(used, card, asset, spent); + if (recent.getComponentForm() != null) { + recent.getComponentForm().revalidate(); + } + } + + /// How many recent payments the screen lists. + private static final int RECENT = 8; + + private static long startOfToday() { + java.util.Calendar now = java.util.Calendar.getInstance(); + now.set(java.util.Calendar.HOUR_OF_DAY, 0); + now.set(java.util.Calendar.MINUTE, 0); + now.set(java.util.Calendar.SECOND, 0); + now.set(java.util.Calendar.MILLISECOND, 0); + return now.getTime().getTime(); + } + + /// A bar: how much of something is used. Turns to the warning colour + /// past 80%. + static final class Meter extends Component { + private final float used; + + Meter(float used) { + this.used = used; + setUIID(used > 0.8f ? "BnMeterHigh" : "BnMeter"); + } + + @Override + protected com.codename1.ui.geom.Dimension calcPreferredSize() { + return new com.codename1.ui.geom.Dimension(CN.convertToPixels(20f), + CN.convertToPixels(1.4f)); + } + + @Override + public void paint(com.codename1.ui.Graphics g) { + // The track is the style's background; the bar is its colour, + // from the start edge (the right one in Hebrew). + int w = Math.round(getWidth() * used); + if (w <= 0) { + return; + } + g.setColor(getStyle().getFgColor()); + boolean anti = g.isAntiAliased(); + g.setAntiAliased(true); + int x = isRTL() ? getX() + getWidth() - w : getX(); + g.fillRoundRect(x, getY(), w, getHeight(), getHeight(), getHeight()); + g.setAntiAliased(anti); + } + } + + private static void limit(final Container content, CardDto card, String asset) { + TextField amount = Ui.field("Daily limit", TextField.DECIMAL); + amount.setText(Amounts.plain(card.dailyLimit(), asset)); + Command ok = new Command(Lang.tr("Save")); + if (Dialog.show(Lang.tr("Daily limit"), amount, ok, new Command(Lang.tr("Cancel"))) != ok) { + return; + } + long units = Amounts.parse(amount.getText(), asset); + if (units < 0) { + Dialog.show(Lang.tr("Daily limit"), Ui.amountHint(), + Lang.tr("OK"), null); + return; + } + // Signed always; the server asks for it only when the limit goes up. + final CardLimitDto limit = new CardLimitDto(units); + DeviceSetup.withDevice(retry -> Api.banking().cardLimit(card.id(), + RequestSigner.sign("PUT", "/api/cards/" + card.id() + "/limit", null, limit), limit, + Net.to((CardDto c) -> load(content), retry)), + (status, message) -> Dialog.show(Lang.tr("Not changed"), Lang.tr(message), + Lang.tr("OK"), null)); + } + + private static AccountDto accountOf(List accounts, String accountId) { + for (AccountDto account : accounts) { + if (account.id().equals(accountId)) { + return account; + } + } + return null; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Charts.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Charts.java new file mode 100644 index 00000000000..46e62551eaf --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Charts.java @@ -0,0 +1,419 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.charts.ChartComponent; +import com.codename1.charts.models.MultipleCategorySeries; +import com.codename1.charts.models.RangeCategorySeries; +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.models.XYSeries; +import com.codename1.charts.renderers.DefaultRenderer; +import com.codename1.charts.renderers.SimpleSeriesRenderer; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.views.BarChart; +import com.codename1.charts.views.DoughnutChart; +import com.codename1.charts.views.LineChart; +import com.codename1.charts.views.RangeBarChart; +import com.codename1.charts.util.NumberFormat; +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import java.util.Date; + +/// The app's charts, drawn by `com.codename1.charts`. +/// +/// Nothing here names a colour: each is the foreground of a style in +/// `theme.css` (`BnChartLine`, `BnChartIn`, `BnChartOut`, `BnChartGrid`, +/// `BnChartText`, `BnChartSlice1`..`BnChartSlice5`), so dark mode and a +/// re-skin reach the charts the way they reach everything else. +/// +/// Time runs left to right in every language, Hebrew included: a chart is a +/// picture of a time line, not a line of text, and the frame around it (the +/// title, the legend rows) is what mirrors. +public final class Charts { + private static final float HEIGHT_MM = 34f; + + private Charts() { + } + + /// Axis numbers the way a statement writes them in a margin: 950, + /// 12.5k, 3.2M. + private static final NumberFormat COMPACT = new NumberFormat() { + @Override + public String format(double value) { + return compact(value); + } + }; + + private static void money(XYMultipleSeriesRenderer renderer, final String prefix) { + if (prefix == null || prefix.length() == 0) { + return; + } + renderer.setYLabelFormat(new NumberFormat() { + @Override + public String format(double value) { + return value < 0 ? "-" + prefix + compact(-value) : prefix + compact(value); + } + }, 0); + } + + static String compact(double value) { + double size = Math.abs(value); + if (size >= 1000000) { + return trim(value / 1000000) + "M"; + } + if (size >= 1000) { + return trim(value / 1000) + "k"; + } + if (size >= 100 || value == Math.floor(value)) { + return String.valueOf((long) Math.floor(value + 0.5)); + } + if (size >= 1 || size == 0) { + return trim(value); + } + // Volumes of a coin are fractions: keep two significant digits. + double scale = 1; + while (size * scale < 10) { + scale *= 10; + } + return String.valueOf(Math.floor(value * scale + 0.5) / scale); + } + + private static String trim(double value) { + long tenths = (long) Math.floor(value * 10 + 0.5); + return tenths % 10 == 0 ? String.valueOf(tenths / 10) + : (tenths / 10) + "." + Math.abs(tenths % 10); + } + + /// The colour of style `uiid`, opaque, as the chart library wants it. + static int color(String uiid) { + Style style = UIManager.getInstance().getComponentStyle(uiid); + return 0xff000000 | style.getFgColor(); + } + + private static int text() { + return CN.convertToPixels(2.3f); + } + + /// A line of `values` (one a day), filled below, with the first and the + /// last day written under it. + /// + /// @param name what a test finds the chart by + public static Component line(String name, long[] days, double[] values) { + return line(name, days, values, ""); + } + + /// The same line, its axis written as money: `prefix` is the currency + /// sign, as in $8k. + public static Component line(String name, long[] days, double[] values, String prefix) { + XYMultipleSeriesDataset data = new XYMultipleSeriesDataset(); + XYSeries series = new XYSeries(""); + for (int iter = 0; iter < values.length; iter++) { + series.add(iter, values[iter]); + } + data.addSeries(series); + XYMultipleSeriesRenderer renderer = frame(); + XYSeriesRenderer one = new XYSeriesRenderer(); + int brand = color("BnChartLine"); + one.setColor(brand); + one.setLineWidth(CN.convertToPixels(0.5f)); + XYSeriesRenderer.FillOutsideLine fill = new XYSeriesRenderer.FillOutsideLine( + XYSeriesRenderer.FillOutsideLine.Type.BOUNDS_ALL); + fill.setColor((brand & 0x00ffffff) | 0x33000000); + one.addFillOutsideLine(fill); + renderer.addSeriesRenderer(one); + dayLabels(renderer, days); + range(renderer, values, values, false); + money(renderer, prefix); + return box(name, new ChartComponent(new LineChart(data, renderer))); + } + + /// Money in and money out, a pair of bars a day. + public static Component inOut(String name, long[] days, double[] in, double[] out) { + XYMultipleSeriesDataset data = new XYMultipleSeriesDataset(); + XYSeries ins = new XYSeries(Lang.tr("Money in")); + XYSeries outs = new XYSeries(Lang.tr("Money out")); + for (int iter = 0; iter < days.length; iter++) { + ins.add(iter, in[iter]); + outs.add(iter, out[iter]); + } + data.addSeries(ins); + data.addSeries(outs); + XYMultipleSeriesRenderer renderer = frame(); + renderer.addSeriesRenderer(bar("BnChartIn")); + renderer.addSeriesRenderer(bar("BnChartOut")); + renderer.setBarSpacing(0.35); + dayLabels(renderer, days); + range(renderer, in, out, true); + Container box = box(name, new ChartComponent(new BarChart(data, renderer, + BarChart.Type.DEFAULT))); + box.add(BorderLayout.NORTH, legend(new String[] {"Money in", "Money out"}, + new String[] {"BnChartIn", "BnChartOut"})); + return box; + } + + /// Bars of `values`, one series, in `uiid`'s colour. + public static Component bars(String name, long[] days, double[] values, String uiid) { + return bars(name, days, values, uiid, ""); + } + + /// Bars with a money axis; see [#line(String, long\[\], double\[\], String)]. + public static Component bars(String name, long[] days, double[] values, String uiid, + String prefix) { + return bars(name, days, values, uiid, prefix, 0.6f); + } + + /// Bars `scale` times the standard chart height (a strip under the + /// candles is 0.6, a panel of its own is taller). + public static Component bars(String name, long[] days, double[] values, String uiid, + String prefix, float scale) { + XYMultipleSeriesDataset data = new XYMultipleSeriesDataset(); + XYSeries series = new XYSeries(""); + for (int iter = 0; iter < values.length; iter++) { + series.add(iter, values[iter]); + } + data.addSeries(series); + XYMultipleSeriesRenderer renderer = frame(); + renderer.addSeriesRenderer(bar(uiid)); + renderer.setBarSpacing(0.25); + dayLabels(renderer, days); + range(renderer, values, values, true); + money(renderer, prefix); + return box(name, new ChartComponent(new BarChart(data, renderer, BarChart.Type.DEFAULT)), + scale); + } + + /// Candles: a body from the open to the close, rising in `BnChartIn`'s + /// colour and falling in `BnChartOut`'s, over a pale bar from the low to + /// the high. The chart library draws ranges, not wicks; a range one + /// shade lighter behind the body reads the same. + public static Component candles(String name, long[] starts, double[] open, double[] high, + double[] low, double[] close) { + RangeCategorySeries range = new RangeCategorySeries("range"); + RangeCategorySeries up = new RangeCategorySeries("up"); + RangeCategorySeries down = new RangeCategorySeries("down"); + for (int iter = 0; iter < open.length; iter++) { + range.add(low[iter], high[iter]); + boolean rising = close[iter] >= open[iter]; + double top = Math.max(open[iter], close[iter]); + double bottom = Math.min(open[iter], close[iter]); + if (top - bottom < (high[iter] - low[iter]) / 50) { + // A doji still shows as a line. + top = bottom + Math.max((high[iter] - low[iter]) / 50, 1e-9); + } + if (rising) { + up.add(bottom, top); + down.add(bottom, bottom); + } else { + up.add(bottom, bottom); + down.add(bottom, top); + } + } + XYMultipleSeriesDataset data = new XYMultipleSeriesDataset(); + data.addSeries(range.toXYSeries()); + data.addSeries(up.toXYSeries()); + data.addSeries(down.toXYSeries()); + XYMultipleSeriesRenderer renderer = frame(); + // The hour's whole range, quiet behind the open-close body. + XYSeriesRenderer shadow = bar("BnChartText"); + renderer.addSeriesRenderer(shadow); + renderer.addSeriesRenderer(bar("BnChartIn")); + renderer.addSeriesRenderer(bar("BnChartOut")); + renderer.setBarSpacing(0.3); + renderer.setXAxisMin(0.5); + renderer.setXAxisMax(open.length + 0.5); + hourLabels(renderer, starts); + range(renderer, low, high, false); + return box(name, new ChartComponent(new RangeBarChart(data, renderer, + BarChart.Type.STACKED))); + } + + /// A ring of `values`, one slice each, with a legend of `labels` and the + /// share of each beside it. + public static Component donut(String name, String[] labels, double[] values, + String[] shares) { + MultipleCategorySeries series = new MultipleCategorySeries(""); + series.add("", labels, values); + DefaultRenderer renderer = new DefaultRenderer(); + String[] uiids = new String[labels.length]; + for (int iter = 0; iter < labels.length; iter++) { + uiids[iter] = "BnChartSlice" + (iter % 5 + 1); + SimpleSeriesRenderer slice = new SimpleSeriesRenderer(); + slice.setColor(color(uiids[iter])); + renderer.addSeriesRenderer(slice); + } + renderer.setShowLabels(false); + renderer.setShowLegend(false); + renderer.setPanEnabled(false); + renderer.setZoomEnabled(false); + renderer.setInScroll(true); + renderer.setBackgroundColor(0); + renderer.setApplyBackgroundColor(false); + renderer.setMargins(new int[] {0, 0, 0, 0}); + ChartComponent chart = new ChartComponent(new DoughnutChart(series, renderer)); + chart.setFocusable(false); + chart.setName(name); + final int size = CN.convertToPixels(26f); + chart.setPreferredSize(new Dimension(size, size)); + chart.setUIID("BnChart"); + Container legend = new Container(BoxLayout.y()); + for (int iter = 0; iter < labels.length; iter++) { + Container row = new Container(new BorderLayout()); + Label swatch = new Label(" ", "BnSwatch"); + swatch.getAllStyles().setBgColor(color(uiids[iter]) & 0xffffff); + swatch.getAllStyles().setBgTransparency(255); + row.add(BorderLayout.WEST, swatch); + row.add(BorderLayout.CENTER, new Label(labels[iter], "BnLegend")); + row.add(BorderLayout.EAST, new Label(shares[iter], "BnLegendValue")); + legend.add(row); + } + Container box = new Container(new BorderLayout(), "BnChartBox"); + box.add(BorderLayout.WEST, chart); + box.add(BorderLayout.CENTER, legend); + return box; + } + + /// A legend of coloured squares, one per series, in a row. + static Container legend(String[] labels, String[] uiids) { + Container row = new Container(BoxLayout.x()); + for (int iter = 0; iter < labels.length; iter++) { + Label swatch = new Label(" ", "BnSwatch"); + swatch.getAllStyles().setBgColor(color(uiids[iter]) & 0xffffff); + swatch.getAllStyles().setBgTransparency(255); + row.add(swatch); + row.add(new Label(Lang.tr(labels[iter]), "BnLegend")); + } + return row; + } + + private static XYSeriesRenderer bar(String uiid) { + XYSeriesRenderer one = new XYSeriesRenderer(); + one.setColor(color(uiid)); + return one; + } + + /// The axes, grid and text every XY chart here shares. + private static XYMultipleSeriesRenderer frame() { + XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer(); + int grid = color("BnChartGrid"); + int label = color("BnChartText"); + renderer.setAxesColor(grid); + renderer.setGridColor(grid); + renderer.setShowGridY(true); + renderer.setShowGridX(false); + renderer.setLabelsColor(label); + renderer.setXLabelsColor(label); + renderer.setYLabelsColor(0, label); + renderer.setLabelsTextSize(text()); + renderer.setShowLegend(false); + renderer.setPanEnabled(false, false); + renderer.setZoomEnabled(false, false); + renderer.setInScroll(true); + // The margins are painted opaque whatever their alpha, so they and + // the plot take the box's own background and the chart has no frame. + int paper = 0xff000000 + | UIManager.getInstance().getComponentStyle("BnChartBox").getBgColor(); + renderer.setApplyBackgroundColor(true); + renderer.setBackgroundColor(paper); + renderer.setMarginsColor(paper); + renderer.setXLabels(0); + renderer.setYLabels(4); + renderer.setYLabelsAlign(Component.RIGHT); + renderer.setYLabelsPadding(CN.convertToPixels(1f)); + int m = CN.convertToPixels(1f); + // The library already keeps room under the axis for its labels (and an + // axis title this chart has none of): no bottom margin on top of it. + renderer.setAxisTitleTextSize(0); + renderer.setMargins(new int[] {m * 2, CN.convertToPixels(11f), 0, m * 5}); + renderer.setPointSize(0); + renderer.setYLabelFormat(COMPACT, 0); + return renderer; + } + + /// The y axis from zero (or the lowest value, for a price) to a little + /// over the highest. + private static void range(XYMultipleSeriesRenderer renderer, double[] a, double[] b, + boolean fromZero) { + double lo = Double.MAX_VALUE; + double hi = -Double.MAX_VALUE; + for (int iter = 0; iter < a.length; iter++) { + lo = Math.min(lo, Math.min(a[iter], b[iter])); + hi = Math.max(hi, Math.max(a[iter], b[iter])); + } + if (a.length == 0) { + lo = 0; + hi = 1; + } + if (fromZero || lo > 0 && lo < (hi - lo) / 3) { + lo = Math.min(0, lo); + } + double pad = Math.max((hi - lo) * 0.08, Math.abs(hi) * 0.01 + 1e-9); + renderer.setYAxisMin(fromZero && lo >= 0 ? 0 : lo - pad); + renderer.setYAxisMax(hi + pad); + } + + private static void dayLabels(XYMultipleSeriesRenderer renderer, long[] days) { + if (days.length == 0) { + return; + } + SimpleDateFormat format = new SimpleDateFormat("MMM d"); + int[] at = {0, days.length / 2, days.length - 1}; + for (int iter = 0; iter < at.length; iter++) { + renderer.addTextLabel(at[iter], format.format(new Date(days[at[iter]]))); + } + renderer.setXAxisMin(-0.5); + renderer.setXAxisMax(days.length - 0.5); + } + + private static void hourLabels(XYMultipleSeriesRenderer renderer, long[] starts) { + if (starts.length == 0) { + return; + } + SimpleDateFormat format = new SimpleDateFormat("HH:mm"); + int[] at = {0, starts.length / 2, starts.length - 1}; + for (int iter = 0; iter < at.length; iter++) { + renderer.addTextLabel(at[iter] + 1, format.format(new Date(starts[at[iter]]))); + } + } + + private static Container box(String name, ChartComponent chart) { + return box(name, chart, 1f); + } + + private static Container box(String name, ChartComponent chart, float scale) { + chart.setName(name); + chart.setFocusable(false); + chart.setUIID("BnChart"); + chart.setPreferredH(CN.convertToPixels(HEIGHT_MM * scale)); + Container box = new Container(new BorderLayout(), "BnChartBox"); + // Mirrored by nothing: see the class comment. + box.setRTL(false); + chart.setRTL(false); + box.add(BorderLayout.CENTER, chart); + return box; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/ConsoleForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/ConsoleForm.java new file mode 100644 index 00000000000..a168dac2155 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/ConsoleForm.java @@ -0,0 +1,1320 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.io.FileSystemStorage; +import com.codename1.io.Util; +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Command; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.spinner.Picker; +import com.codenameone.examples.bastion.api.AdjustmentDto; +import com.codenameone.examples.bastion.api.ApprovalDto; +import com.codenameone.examples.bastion.api.AssetTotalDto; +import com.codenameone.examples.bastion.api.AuditEventDto; +import com.codenameone.examples.bastion.api.BalanceSheetDto; +import com.codenameone.examples.bastion.api.BreakDto; +import com.codenameone.examples.bastion.api.ChainReportDto; +import com.codenameone.examples.bastion.api.CustodyDto; +import com.codenameone.examples.bastion.api.ChartAccountDto; +import com.codenameone.examples.bastion.api.ClosePeriodDto; +import com.codenameone.examples.bastion.api.ControlDto; +import com.codenameone.examples.bastion.api.DecisionDto; +import com.codenameone.examples.bastion.api.EvidenceDto; +import com.codenameone.examples.bastion.api.GeneralLedgerDto; +import com.codenameone.examples.bastion.api.IncomeStatementDto; +import com.codenameone.examples.bastion.api.JournalEntryDto; +import com.codenameone.examples.bastion.api.JournalLegDto; +import com.codenameone.examples.bastion.api.LedgerLineDto; +import com.codenameone.examples.bastion.api.PeriodDto; +import com.codenameone.examples.bastion.api.ReconciliationDto; +import com.codenameone.examples.bastion.api.ReversalRequestDto; +import com.codenameone.examples.bastion.api.StatementLineDto; +import com.codenameone.examples.bastion.api.StatementTotalDto; +import com.codenameone.examples.bastion.api.TAccountDto; +import com.codenameone.examples.bastion.api.TrialBalanceDto; +import com.codenameone.examples.bastion.api.TrialBalanceLineDto; +import com.codenameone.examples.bastion.api.UserDto; +import com.codenameone.examples.bastion.api.VerificationDto; +import com.codenameone.examples.bastion.api.VolumeDayDto; +import com.codenameone.examples.bastion.money.Amounts; +import com.codenameone.examples.bastion.money.Assets; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.security.RequestSigner; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +/// The back office: the bank's books and its audit log, for its staff. +/// +/// One screen for both roles. An auditor reads everything and changes +/// nothing; an administrator also closes periods, raises adjustments and +/// reversals and decides those another administrator raised. What a role +/// may do is the server's rule (`SecurityConfig`, `Approvals`): the buttons +/// shown here follow it, they do not enforce it. +/// +/// On a wide window -- a desktop, the web on a monitor, a tablet on its +/// side -- the sections are a sidebar beside the page and the tables use the +/// width. On a phone the sections are a picker over a single column, and a +/// table wider than the phone scrolls sideways. +/// +/// Vocabulary is the accountant's: a journal of entries, each a set of debit +/// (Dr) and credit (Cr) legs that sum to zero per currency; a general ledger +/// line per leg with its running balance; a trial balance proving debits +/// equal credits; a balance sheet (assets = liabilities + equity) and an +/// income statement for a period; reconciliation of control accounts to +/// their subledgers, with any break shown in red. +public final class ConsoleForm { + /// A window at least this wide, in millimetres, gets the sidebar. + private static final float WIDE_MM = 165f; + + static final String[] SECTIONS = {"Overview", "Journal", "General ledger", "Trial balance", + "Balance sheet", "Income statement", "Reconciliation", "Period close", "Approvals", + "Audit chain", "Audit trail", "Access log", "Evidence"}; + private static final char[] ICONS = {FontImage.MATERIAL_DASHBOARD, + FontImage.MATERIAL_MENU_BOOK, FontImage.MATERIAL_ACCOUNT_TREE, + FontImage.MATERIAL_BALANCE, FontImage.MATERIAL_ACCOUNT_BALANCE, + FontImage.MATERIAL_TRENDING_UP, FontImage.MATERIAL_COMPARE_ARROWS, + FontImage.MATERIAL_EVENT_AVAILABLE, FontImage.MATERIAL_FACT_CHECK, + FontImage.MATERIAL_LINK, FontImage.MATERIAL_MANAGE_SEARCH, + FontImage.MATERIAL_BADGE, FontImage.MATERIAL_FILE_DOWNLOAD}; + + private static int current; + + private final Form form; + private final Container pane = new Container(BoxLayout.y()); + private final Container side; + /// The page of the section on screen, under its title. + private Container body; + private final boolean wide; + private final boolean admin; + + private ConsoleForm(int section) { + UserDto user = Session.user(); + admin = user != null && user.admin(); + wide = wide(); + form = Nav.form(admin ? "Administration" : "Audit", null); + form.setName("console"); + pane.setName("console-page"); + if (wide) { + form.setScrollable(false); + form.setLayout(new BorderLayout()); + side = new Container(BoxLayout.y(), "BnSide"); + side.setScrollableY(true); + side.setName("console-sections"); + // The title bar already names the console; the sidebar starts + // with the sections. + if (!admin) { + side.add(new Label(Lang.tr("Read-only"), "BnBadgeWarn")); + } + for (int iter = 0; iter < SECTIONS.length; iter++) { + if (!admin && "Approvals".equals(SECTIONS[iter])) { + continue; + } + final int index = iter; + Button item = new Button(Lang.tr(SECTIONS[iter]), "BnSideItem"); + FontImage.setMaterialIcon(item, ICONS[iter], 3.4f); + item.addActionListener(evt -> open(index)); + side.add(item); + } + Container page = new Container(BoxLayout.y(), "BnConsolePage"); + page.setScrollableY(true); + page.add(pane); + form.add(BorderLayout.WEST, side); + form.add(BorderLayout.CENTER, page); + } else { + side = null; + List ids = new ArrayList(); + List labels = new ArrayList(); + for (int iter = 0; iter < SECTIONS.length; iter++) { + if (!admin && "Approvals".equals(SECTIONS[iter])) { + continue; + } + ids.add(String.valueOf(iter)); + labels.add(Lang.tr(SECTIONS[iter])); + } + final Picker which = Ui.picker(ids.toArray(new String[ids.size()]), + labels.toArray(new String[labels.size()]), String.valueOf(section)); + which.setName("console-section"); + which.addActionListener(evt -> { + String picked = Ui.pickedId(which); + if (picked != null) { + open(Integer.parseInt(picked)); + } + }); + if (!admin) { + form.add(FlowLayout.encloseIn(new Label(Lang.tr("Read-only"), "BnBadgeWarn"))); + } + form.add(which); + form.add(pane); + } + final boolean was = wide; + form.addSizeChangedListener(evt -> { + if (wide() != was) { + CN.callSerially(() -> new ConsoleForm(current).start()); + } + }); + } + + /// Opens the console on the section shown last. + public static void show() { + Assets.load(() -> new ConsoleForm(current).start()); + } + + static boolean wide() { + return CN.getDisplayWidth() >= CN.convertToPixels(WIDE_MM); + } + + private void start() { + Ui.show(form, true); + open(current); + } + + private void open(int section) { + current = section; + if (side != null) { + for (int iter = 0; iter < side.getComponentCount(); iter++) { + Component c = side.getComponentAt(iter); + if (c instanceof Button) { + Button b = (Button) c; + boolean on = b.getText().equals(Lang.tr(SECTIONS[section])); + b.setUIID(on ? "BnSideItemOn" : "BnSideItem"); + FontImage.setMaterialIcon(b, ICONS[indexOf(b.getText())], 3.4f); + } + } + } + String name = SECTIONS[section]; + pane.removeAll(); + pane.add(new Label(Lang.tr(name), "BnConsoleTitle")); + Container body = new Container(BoxLayout.y()); + this.body = body; + pane.add(body); + Ui.loading(body); + form.revalidate(); + if ("Overview".equals(name)) { + overview(body); + } else if ("Journal".equals(name)) { + journal(body); + } else if ("General ledger".equals(name)) { + ledger(body, "2000", Assets.REPORTING); + } else if ("Trial balance".equals(name)) { + trialBalance(body); + } else if ("Balance sheet".equals(name)) { + balanceSheet(body); + } else if ("Income statement".equals(name)) { + incomeStatement(body); + } else if ("Reconciliation".equals(name)) { + reconciliation(body); + } else if ("Period close".equals(name)) { + periods(body); + } else if ("Approvals".equals(name)) { + approvals(body); + } else if ("Audit chain".equals(name)) { + chain(body); + } else if ("Audit trail".equals(name)) { + trail(body, ""); + } else if ("Access log".equals(name)) { + accessLog(body); + } else { + evidence(body); + } + } + + private static int indexOf(String translated) { + for (int iter = 0; iter < SECTIONS.length; iter++) { + if (Lang.tr(SECTIONS[iter]).equals(translated)) { + return iter; + } + } + return 0; + } + + private void done(Container body) { + body.revalidate(); + form.revalidate(); + } + + private Net.Failed failed(final Container body, final Runnable retry) { + return (status, message) -> { + Ui.failed(body, message, retry); + form.revalidate(); + }; + } + + // ------------------------------------------------------------- overview + + private void overview(final Container body) { + body.removeAll(); + final Container tiles = new Container(wide ? new GridLayout(1, 4) : new GridLayout(2, 2)); + final Label trial = tile(tiles, "Trial balance"); + final Label recon = tile(tiles, "Reconciliation"); + final Label chain = tile(tiles, "Audit chain"); + final Label pending = tile(tiles, admin ? "Awaiting approval" : "Journal entries"); + body.add(tiles); + final Container charts = new Container(wide ? new GridLayout(1, 2) : BoxLayout.y()); + body.add(charts); + Container checks = new Container(wide ? new GridLayout(1, 2) : BoxLayout.y()); + final Container auditBox = new Container(BoxLayout.y()); + final Container ledgerBox = new Container(BoxLayout.y()); + Button verifyAudit = Ui.primary("Verify the audit log"); + verifyAudit.addActionListener(evt -> { + Ui.loading(auditBox); + Api.admin().verifyAudit(Net.to((VerificationDto v) -> verified(auditBox, v, + "The audit chain is intact."), failed(auditBox, () -> { }))); + }); + Button verifyLedger = Ui.primary("Verify the ledger"); + verifyLedger.addActionListener(evt -> { + Ui.loading(ledgerBox); + Api.admin().verifyLedger(Net.to((VerificationDto v) -> verified(ledgerBox, v, + "Every entry balances and every balance matches its postings."), + failed(ledgerBox, () -> { }))); + }); + if (admin) { + body.add(Ui.section("Integrity checks")); + checks.add(BoxLayout.encloseY(verifyAudit, auditBox)); + checks.add(BoxLayout.encloseY(verifyLedger, ledgerBox)); + Ui.gaps(checks); + body.add(checks); + } + final Container custody = new Container(BoxLayout.y()); + body.add(custody); + done(body); + // Only a coin that settles on a real chain has custody to show; the + // report answers live=false otherwise and the section stays empty. + Api.books().custody(Net.to((CustodyDto c) -> { + if (!c.live()) { + return; + } + boolean backed = c.onChain() >= c.owed(); + custody.add(Ui.section("Custody")); + custody.add(Ui.row(Lang.tr("Held on chain {0}", String.valueOf(c.chainId())), + Lang.tr("Owed to customers {0}", Amounts.format(c.owed(), c.asset())), + Amounts.format(c.onChain(), c.asset()), + backed ? "BnBadgeGood" : "BnBadgeBad", null)); + custody.add(Ui.row(Lang.tr("Hot wallet"), c.hotWallet(), + Amounts.format(c.hotBalance(), c.asset()), "BnRowValue", null)); + custody.add(Ui.row(Lang.tr("In transit"), Lang.tr("Withdrawals not yet confirmed"), + Amounts.format(c.inTransit(), c.asset()), "BnRowValue", null)); + body.revalidate(); + }, (s, m) -> { })); + Api.books().trialBalance(null, Net.to((TrialBalanceDto tb) -> state(trial, + tb.balanced(), "Balanced", "Out of balance"), (s, m) -> state(trial, false, + "", "Unavailable"))); + Api.books().reconciliation(Net.to((ReconciliationDto r) -> state(recon, r.clean(), + "No breaks", Lang.tr("{0} breaks", String.valueOf(r.breaks().size()))), + (s, m) -> state(recon, false, "", "Unavailable"))); + Api.audit().chain(Net.to((ChainReportDto c) -> state(chain, c.intact(), "Intact", + Lang.tr("Broken at #{0}", String.valueOf(c.firstBrokenSeq()))), + (s, m) -> state(chain, false, "", "Unavailable"))); + if (admin) { + Api.admin().approvals(Net.to((List list) -> { + int open = 0; + for (ApprovalDto a : list) { + if ("PENDING".equals(a.status())) { + open++; + } + } + pending.setText(String.valueOf(open)); + pending.setUIID(open == 0 ? "BnTileValue" : "BnTileValueWarn"); + tiles.revalidate(); + }, (s, m) -> pending.setText("-"))); + } + Api.books().volume("30", Net.to((List days) -> { + volumeCharts(charts, days, pending); + }, (s, m) -> { })); + } + + private void volumeCharts(Container charts, List days, Label entries) { + List dayList = new ArrayList(); + for (VolumeDayDto d : days) { + Long day = Long.valueOf(d.day()); + if (!dayList.contains(day)) { + dayList.add(day); + } + } + Collections.sort(dayList); + long[] at = new long[dayList.size()]; + double[] volume = new double[at.length]; + double[] count = new double[at.length]; + int total = 0; + for (int iter = 0; iter < at.length; iter++) { + at[iter] = dayList.get(iter).longValue(); + } + for (VolumeDayDto d : days) { + int i = dayList.indexOf(Long.valueOf(d.day())); + // A trade moves two assets and is in both rows; the day's + // distinct count is on each of them, so it is read, not summed. + if (count[i] == 0) { + count[i] = d.dayEntries(); + total += d.dayEntries(); + } + if (Assets.REPORTING.equals(d.asset())) { + volume[i] += d.volume() / 100.0; + } + } + if (!admin) { + entries.setText(String.valueOf(total)); + } + charts.removeAll(); + charts.add(panel("Dollar volume, 30 days", Charts.bars("volume-chart", at, volume, + "BnChartLine", "$", 1.5f))); + charts.add(panel("Journal entries a day", Charts.bars("entries-chart", at, count, + "BnChartIn", "", 1.5f))); + Ui.gaps(charts); + charts.revalidate(); + form.revalidate(); + } + + private static Container panel(String title, Component chart) { + Container box = new Container(BoxLayout.y(), "BnPanel"); + box.add(new Label(Lang.tr(title), "BnPanelTitle")); + box.add(chart); + return box; + } + + private static Label tile(Container tiles, String caption) { + Container tile = new Container(BoxLayout.y(), "BnTile"); + Label value = new Label("...", "BnTileValue"); + tile.add(value); + tile.add(new Label(Lang.tr(caption), "BnTileLabel")); + tiles.add(tile); + return value; + } + + private void state(Label value, boolean good, String yes, String no) { + value.setText(Lang.tr(good ? yes : no)); + value.setUIID(good ? "BnTileValueGood" : "BnTileValueBad"); + value.getParent().revalidate(); + } + + private void verified(Container box, VerificationDto v, String good) { + box.removeAll(); + if (v.intact()) { + // The sentence wraps; a row's title would cut it off. + Container line = new Container(new BorderLayout(), "BnRow"); + SpanLabel says = new SpanLabel(Lang.tr(good)); + says.setTextUIID("BnRowTitle"); + line.add(BorderLayout.CENTER, says); + line.add(BorderLayout.EAST, BorderLayout.centerCenter( + new Label(Lang.tr("Intact"), "BnBadgeGood"))); + box.add(line); + } else { + box.add(Ui.row(Lang.tr("Problems found"), null, String.valueOf(v.problems().size()), + "BnBadgeBad", null)); + for (String problem : v.problems()) { + box.add(Ui.muted(problem)); + } + } + done(box); + } + + // -------------------------------------------------------------- journal + + private void journal(final Container body) { + Api.books().journal(null, null, "60", Net.to((List entries) -> { + body.removeAll(); + body.add(Ui.muted(Lang.tr("Every entry, newest first. Debits and credits of an " + + "entry sum to the same amount in each currency. Tap an entry for its legs."))); + Table table = new Table(new String[] {"Date", "Entry", "Account", "Debit (Dr)", + "Credit (Cr)"}, new int[] {4, 3, 8, 3, 3}, + new boolean[] {false, false, false, true, true}); + for (final JournalEntryDto e : entries) { + table.add(new String[] {when(e.at()), shortId(e.id()), kindName(e.kind()) + + (e.memo() == null || e.memo().length() == 0 ? "" : " - " + e.memo()), + "", ""}, "BnTableGroup", null, () -> entry(body, e.id())); + for (JournalLegDto leg : e.legs()) { + boolean credit = leg.credit() != 0; + table.add(new String[] {"", "", (credit ? " " : "") + leg.code() + " " + + leg.accountName() + owner(leg.owner()), + dr(leg.debit(), leg.asset()), dr(leg.credit(), leg.asset())}, null); + } + } + if (entries.isEmpty()) { + table.note(Lang.tr("No entries yet.")); + } + body.add(table.component()); + done(body); + }, failed(body, () -> journal(body)))); + } + + /// One journal entry with all of its legs. + private void entry(final Container body, final String id) { + Ui.loading(body); + Api.books().entry(id, Net.to((JournalEntryDto e) -> { + body.removeAll(); + Button back = Ui.secondary("All entries"); + back.addActionListener(evt -> journal(body)); + body.add(FlowLayout.encloseIn(back)); + body.add(new Label(Lang.tr("Journal entry {0}", shortId(e.id())), "BnSubTitle")); + Container facts = new Container(new GridLayout(wide ? 2 : 1, wide ? 3 : 1)); + facts.add(fact("Posted", when(e.at()))); + facts.add(fact("Type", kindName(e.kind()))); + facts.add(fact("Initiated by", e.initiator())); + facts.add(fact("Memo", e.memo())); + facts.add(fact("Reverses", e.reverses() == null ? "-" : shortId(e.reverses()))); + facts.add(fact("Reversed by", e.reversedBy() == null ? "-" : shortId(e.reversedBy()))); + body.add(facts); + body.add(Ui.copyable(Lang.tr("Entry id"), e.id(), false)); + Table legs = new Table(new String[] {"Code", "Account", "Owner", "Asset", + "Debit (Dr)", "Credit (Cr)"}, new int[] {2, 6, 4, 2, 3, 3}, + new boolean[] {false, false, false, false, true, true}); + List assets = new ArrayList(); + for (JournalLegDto leg : e.legs()) { + legs.add(new String[] {leg.code(), leg.accountName(), leg.owner(), leg.asset(), + dr(leg.debit(), leg.asset()), dr(leg.credit(), leg.asset())}, null); + if (!assets.contains(leg.asset())) { + assets.add(leg.asset()); + } + } + boolean balanced = true; + for (String asset : assets) { + long d = 0; + long c = 0; + for (JournalLegDto leg : e.legs()) { + if (asset.equals(leg.asset())) { + d += leg.debit(); + c += leg.credit(); + } + } + balanced &= d == c; + legs.add(new String[] {"", Lang.tr("Total"), "", asset, + Amounts.format(d, asset), Amounts.format(c, asset)}, "BnTableTotal", null, null); + } + body.add(legs.component()); + body.add(FlowLayout.encloseIn(new Label(Lang.tr(balanced ? "Balanced" : "Out of balance"), + balanced ? "BnBadgeGood" : "BnBadgeBad"))); + final Printout.Section legRows = legs.printable(); + printButton(body, () -> new Printout(Lang.tr("Journal entry {0}", shortId(e.id())), + System.currentTimeMillis()) + .fact(Lang.tr("Entry id"), e.id()) + .fact(Lang.tr("Posted"), when(e.at())) + .fact(Lang.tr("Type"), kindName(e.kind())) + .fact(Lang.tr("Memo"), e.memo()) + .add(legRows)); + if (admin && e.reversedBy() == null && !"REVERSAL".equals(e.kind())) { + Button reverse = Ui.secondary("Reverse this entry"); + reverse.addActionListener(evt -> reverse(body, e)); + body.add(reverse); + } + Button trail = Ui.secondary("Audit trail of this entry"); + trail.addActionListener(evt -> { + current = indexOfSection("Audit trail"); + openTrail(e.id()); + }); + body.add(trail); + done(body); + }, failed(body, () -> entry(body, id)))); + } + + private void openTrail(String subject) { + open(current); + trail(body, subject); + } + + private static int indexOfSection(String name) { + for (int iter = 0; iter < SECTIONS.length; iter++) { + if (SECTIONS[iter].equals(name)) { + return iter; + } + } + return 0; + } + + private void reverse(final Container body, final JournalEntryDto e) { + TextField memo = Ui.field("Reason", TextField.ANY); + TextField password = password(); + Command go = new Command(Lang.tr("Request reversal")); + if (Dialog.show(Lang.tr("Reverse entry {0}", shortId(e.id())), + BoxLayout.encloseY(Ui.muted(Lang.tr("Above the approval threshold, a second administrator must approve it.")), + memo, password), go, new Command(Lang.tr("Cancel"))) != go) { + return; + } + Api.admin().reverse(e.id(), RequestSigner.idempotencyKey(), + new ReversalRequestDto(memo.getText(), password.getText()), + Net.to((ApprovalDto a) -> { + toastFor(a); + entry(body, e.id()); + }, (s, m) -> Dialog.show(Lang.tr("Not done"), Lang.tr(m), Lang.tr("OK"), null))); + } + + /// Within the threshold a request posts at once; above it, it waits. + private static void toastFor(ApprovalDto a) { + Ui.toast("EXECUTED".equals(a.status()) ? "Posted." : "Sent for approval."); + } + + // ------------------------------------------------------- general ledger + + private static final int LEDGER_ROWS = 60; + /// The server lists at most 2000 postings; its totals cover them all. + private static final String TRUNCATED = + "Only the latest {0} postings are listed; the totals and balances cover them all."; + + private void ledger(final Container body, final String code, final String asset) { + Api.books().chart(Net.to((List chart) -> Api.books().generalLedger(code, + asset, null, null, Net.to((GeneralLedgerDto gl) -> { + List lines = gl.lines(); + body.removeAll(); + String[] ids = new String[chart.size()]; + String[] names = new String[chart.size()]; + for (int iter = 0; iter < ids.length; iter++) { + ids[iter] = chart.get(iter).code(); + names[iter] = chart.get(iter).code() + " " + chart.get(iter).name(); + } + final Picker account = Ui.picker(ids, names, code); + Assets.Asset[] all = Assets.all(); + String[] codes = new String[all.length]; + for (int iter = 0; iter < codes.length; iter++) { + codes[iter] = all[iter].code; + } + final Picker currency = Ui.picker(codes, codes, asset); + account.addActionListener(evt -> ledger(body, Ui.pickedId(account), + Ui.pickedId(currency))); + currency.addActionListener(evt -> ledger(body, Ui.pickedId(account), + Ui.pickedId(currency))); + Container pickers = new Container(new BorderLayout()); + pickers.add(BorderLayout.CENTER, account); + pickers.add(BorderLayout.EAST, currency); + body.add(pickers); + Table table = new Table(new String[] {"Date", "Entry", "Memo", "Owner", + "Debit (Dr)", "Credit (Cr)", "Balance"}, new int[] {3, 2, 5, 3, 3, 3, 3}, + new boolean[] {false, false, false, false, true, true, true}); + // A busy control account has thousands of postings; the + // page shows the latest, which is where a reviewer starts. + int first = Math.max(0, lines.size() - LEDGER_ROWS); + if (gl.truncated()) { + table.note(Lang.tr(TRUNCATED, + String.valueOf(lines.size()))); + } + if (first > 0) { + table.note(Lang.tr("Showing the latest {0} of {1} postings.", + String.valueOf(LEDGER_ROWS), String.valueOf(lines.size()))); + } + for (final LedgerLineDto line : lines.subList(first, lines.size())) { + table.add(new String[] {when(line.at()), shortId(line.entryId()), + line.memo() == null || line.memo().length() == 0 + ? kindName(line.kind()) : line.memo(), line.owner(), + dr(line.debit(), line.asset()), dr(line.credit(), line.asset()), + Amounts.format(line.balance(), line.asset())}, () -> { + current = indexOfSection("Journal"); + open(current); + entry(this.body, line.entryId()); + }); + } + if (lines.isEmpty()) { + table.note(Lang.tr("No postings to this account in this currency.")); + } + body.add(table.component()); + final Printout.Section rows = table.printable(); + int named = Ui.indexOf(ids, code); + final String accountName = named < 0 ? code : names[named]; + final String closing = lines.isEmpty() ? null + : Amounts.format(lines.get(lines.size() - 1).balance(), asset); + printButton(body, () -> { + Printout p = new Printout(Lang.tr("General ledger"), + System.currentTimeMillis()) + .fact(Lang.tr("Account"), accountName) + .fact(Lang.tr("Currency"), asset) + .add(rows); + return closing == null ? p : p.total(Lang.tr("Closing balance"), closing); + }); + if (!lines.isEmpty()) { + final String accountId = lines.get(0).accountId(); + Button t = Ui.secondary("Show as a T-account"); + t.addActionListener(evt -> tAccount(body, accountId, code, asset)); + Container actions = new Container(new FlowLayout()); + actions.add(t); + // Adjustments are to customer money only (`Approvals`): + // the bank's own accounts move by the entries that + // explain them. + if (admin && code.startsWith("2") && !"2200".equals(code)) { + Button adjust = Ui.secondary("Raise an adjustment"); + adjust.addActionListener(evt -> adjust(accountId, asset)); + actions.add(adjust); + } + body.add(actions); + } + done(body); + }, failed(body, () -> ledger(body, code, asset)))), + failed(body, () -> ledger(body, code, asset)))); + } + + private void tAccount(final Container body, final String accountId, final String code, + final String asset) { + Ui.loading(body); + Api.books().tAccount(accountId, null, null, Net.to((TAccountDto t) -> { + body.removeAll(); + Button back = Ui.secondary("General ledger"); + back.addActionListener(evt -> ledger(body, code, asset)); + body.add(FlowLayout.encloseIn(back)); + body.add(new Label(t.code() + " " + t.name() + " (" + t.asset() + ")", "BnSubTitle")); + body.add(Ui.muted(Lang.tr("{0} account, normal balance {1}.", + className(t.accountClass()), side(t.normalBalance())))); + if (t.truncated()) { + body.add(Ui.muted(Lang.tr(TRUNCATED, + String.valueOf(t.debits().size() + t.credits().size())))); + } + Container head = new Container(new GridLayout(1, 2), "BnTHead"); + head.add(new Label(Lang.tr("Debit (Dr)"), "BnTSide")); + head.add(new Label(Lang.tr("Credit (Cr)"), "BnTSide")); + body.add(head); + Container columns = new Container(new GridLayout(1, 2)); + Container debits = new Container(BoxLayout.y(), "BnTDebit"); + Container credits = new Container(BoxLayout.y(), "BnTCredit"); + boolean debitNormal = "DEBIT".equals(t.normalBalance()); + // The magnitude, taken from the formatted string: Math.abs(Long.MIN_VALUE) is + // still negative. + String openingAmount = Amounts.format(t.openingBalance(), t.asset()); + if (openingAmount.startsWith("-")) { + openingAmount = openingAmount.substring(1); + } + Container opening = tLine(Lang.tr("Opening balance"), openingAmount, "BnTLineTotal"); + (debitNormal ? debits : credits).add(opening); + for (LedgerLineDto line : t.debits()) { + debits.add(tLine(when(line.at()) + " " + shortId(line.entryId()), + Amounts.format(line.debit(), line.asset()), "BnTLine")); + } + for (LedgerLineDto line : t.credits()) { + credits.add(tLine(when(line.at()) + " " + shortId(line.entryId()), + Amounts.format(line.credit(), line.asset()), "BnTLine")); + } + columns.add(debits); + columns.add(credits); + body.add(columns); + Container totals = new Container(new GridLayout(1, 2), "BnTFoot"); + totals.add(tLine(Lang.tr("Total debits"), Amounts.format(t.totalDebits(), t.asset()), + "BnTLineTotal")); + totals.add(tLine(Lang.tr("Total credits"), Amounts.format(t.totalCredits(), t.asset()), + "BnTLineTotal")); + body.add(totals); + body.add(Ui.row(Lang.tr("Closing balance"), side(t.normalBalance()), + Amounts.format(t.closingBalance(), t.asset()), "BnRowValue", null)); + done(body); + }, failed(body, () -> tAccount(body, accountId, code, asset)))); + } + + private static Container tLine(String left, String right, String uiid) { + Container line = new Container(new BorderLayout(), uiid); + line.add(BorderLayout.CENTER, new Label(left, "BnCell")); + line.add(BorderLayout.EAST, new Label(right, "BnCellNum")); + return line; + } + + private void adjust(final String accountId, final String asset) { + TextField amount = Ui.field("Amount (negative to debit)", TextField.ANY); + TextField memo = Ui.field("Reason", TextField.ANY); + TextField password = password(); + Command go = new Command(Lang.tr("Request adjustment")); + if (Dialog.show(Lang.tr("Adjustment"), BoxLayout.encloseY( + Ui.muted(Lang.tr("Above the approval threshold, a second administrator must approve it.")), amount, memo, + password), go, new Command(Lang.tr("Cancel"))) != go) { + return; + } + String text = amount.getText().trim(); + boolean negative = text.startsWith("-"); + long units = Amounts.parse(negative ? text.substring(1) : text, asset); + if (units <= 0) { + Dialog.show(Lang.tr("Amount"), Ui.amountHint(), Lang.tr("OK"), null); + return; + } + Api.admin().adjust(RequestSigner.idempotencyKey(), new AdjustmentDto(accountId, + negative ? -units : units, memo.getText(), password.getText()), + Net.to((ApprovalDto a) -> toastFor(a), + (s, m) -> Dialog.show(Lang.tr("Not done"), Lang.tr(m), Lang.tr("OK"), + null))); + } + + // ----------------------------------------------------------- statements + + private void trialBalance(final Container body) { + Api.books().trialBalance(null, Net.to((TrialBalanceDto tb) -> { + body.removeAll(); + body.add(FlowLayout.encloseIn(new Label(Lang.tr(tb.balanced() ? "Balanced" + : "Out of balance"), tb.balanced() ? "BnBadgeGood" : "BnBadgeBad"), + new Label(Lang.tr("As of {0}", when(tb.asOf())), "BnCellMuted"))); + Table table = new Table(new String[] {"Code", "Account", "Class", "Asset", + "Debit (Dr)", "Credit (Cr)"}, new int[] {2, 7, 3, 2, 3, 3}, + new boolean[] {false, false, false, false, true, true}); + for (TrialBalanceLineDto line : tb.lines()) { + table.add(new String[] {line.code(), line.name(), className(line.accountClass()), + line.asset(), dr(line.debit(), line.asset()), dr(line.credit(), line.asset())}, + null); + } + for (AssetTotalDto total : tb.totals()) { + table.add(new String[] {"", Lang.tr("Total"), "", total.asset(), + Amounts.format(total.debits(), total.asset()), + Amounts.format(total.credits(), total.asset())}, + total.balanced() ? "BnTableTotal" : "BnTableRowBad", null, null); + } + body.add(table.component()); + final Printout.Section rows = table.printable(); + printButton(body, () -> new Printout(Lang.tr("Trial balance"), + System.currentTimeMillis()) + .fact(Lang.tr("Books"), Lang.tr("All accounts")) + .fact(Lang.tr("As of"), when(tb.asOf())) + .fact(Lang.tr("Status"), Lang.tr(tb.balanced() ? "Balanced" : "Out of balance")) + .add(rows)); + done(body); + }, failed(body, () -> trialBalance(body)))); + } + + private void balanceSheet(final Container body) { + Api.books().balanceSheet(null, Net.to((BalanceSheetDto bs) -> { + body.removeAll(); + body.add(FlowLayout.encloseIn(new Label(Lang.tr(bs.balanced() + ? "Assets = liabilities + equity" : "Out of balance"), + bs.balanced() ? "BnBadgeGood" : "BnBadgeBad"), + new Label(Lang.tr("As of {0}", when(bs.asOf())), "BnCellMuted"))); + body.add(Ui.row(Lang.tr("Total assets"), Lang.tr("Valued in dollars"), + Amounts.format(bs.totalAssetsUsdCents(), "USD"), "BnRowValue", null)); + final List tables = statement(body, bs.lines(), bs.totals(), + new String[] {"ASSETS", "LIABILITIES", "EQUITY"}); + printButton(body, () -> { + Printout p = new Printout(Lang.tr("Balance sheet"), System.currentTimeMillis()) + .fact(Lang.tr("Books"), Lang.tr("All accounts")) + .fact(Lang.tr("As of"), when(bs.asOf())); + for (Printout.Section s : tables) { + p.add(s); + } + return p.total(Lang.tr("Total assets") + " (" + Lang.tr("Valued in dollars") + ")", + Amounts.format(bs.totalAssetsUsdCents(), "USD")); + }); + done(body); + }, failed(body, () -> balanceSheet(body)))); + } + + private void incomeStatement(final Container body) { + Api.books().incomeStatement(null, null, Net.to((IncomeStatementDto is) -> { + body.removeAll(); + body.add(new Label(Lang.tr("{0} to {1}", when(is.from()), when(is.to())), + "BnCellMuted")); + body.add(Ui.row(Lang.tr("Net income"), Lang.tr("Valued in dollars"), + Amounts.format(is.netIncomeUsdCents(), "USD"), + is.netIncomeUsdCents() >= 0 ? "BnAmountIn" : "BnAmountNegative", null)); + final List tables = statement(body, is.lines(), is.totals(), + new String[] {"REVENUE", "EXPENSES"}); + printButton(body, () -> { + Printout p = new Printout(Lang.tr("Income statement"), System.currentTimeMillis()) + .fact(Lang.tr("Books"), Lang.tr("All accounts")) + .fact(Lang.tr("Period"), Lang.tr("{0} to {1}", when(is.from()), + when(is.to()))); + for (Printout.Section s : tables) { + p.add(s); + } + return p.total(Lang.tr("Net income") + " (" + Lang.tr("Valued in dollars") + ")", + Amounts.format(is.netIncomeUsdCents(), "USD")); + }); + done(body); + }, failed(body, () -> incomeStatement(body)))); + } + + private List statement(Container body, List lines, + List totals, String[] sections) { + List printed = new ArrayList(); + for (String section : sections) { + body.add(Ui.section(className(section))); + Table table = new Table(new String[] {"Code", "Account", "Asset", "Amount", + "In dollars"}, new int[] {2, 8, 2, 4, 4}, + new boolean[] {false, false, false, true, true}); + for (StatementLineDto line : lines) { + if (section.equals(line.section())) { + table.add(new String[] {line.code(), line.name(), line.asset(), + Amounts.format(line.amount(), line.asset()), + Amounts.format(line.usdCents(), "USD")}, null); + } + } + for (StatementTotalDto total : totals) { + if (section.equals(total.section())) { + table.add(new String[] {"", Lang.tr("Total"), total.asset(), + Amounts.format(total.amount(), total.asset()), + Amounts.format(total.usdCents(), "USD")}, "BnTableTotal", null, null); + } + } + if (table.size() == 0) { + table.note(Lang.tr("Nothing in this section.")); + } + body.add(table.component()); + printed.add(table.printable().caption(className(section))); + } + return printed; + } + + private void reconciliation(final Container body) { + Api.books().reconciliation(Net.to((ReconciliationDto r) -> { + body.removeAll(); + body.add(FlowLayout.encloseIn(new Label(r.clean() ? Lang.tr("No breaks") + : Lang.tr("{0} breaks", String.valueOf(r.breaks().size())), + r.clean() ? "BnBadgeGood" : "BnBadgeBad"), + new Label(Lang.tr("As of {0}", when(r.at())), "BnCellMuted"))); + body.add(Ui.muted(Lang.tr("Each control account against the sum of its subledger. " + + "A difference is a break and is shown in red."))); + Table controls = new Table(new String[] {"Asset", "Code", "Account", "Control", + "Subledger", "Difference"}, new int[] {2, 2, 6, 3, 3, 3}, + new boolean[] {false, false, false, true, true, true}); + for (ControlDto c : r.controls()) { + controls.add(new String[] {c.asset(), c.code(), c.name(), + Amounts.format(c.control(), c.asset()), Amounts.format(c.subledger(), c.asset()), + Amounts.format(c.difference(), c.asset())}, + c.difference() == 0 ? "BnTableRow" : "BnTableRowBad", null, null); + } + body.add(controls.component()); + body.add(Ui.section("Breaks")); + Table breaks = new Table(new String[] {"Type", "Asset", "Reference", "Expected", + "Actual", "Difference"}, new int[] {3, 2, 5, 3, 3, 3}, + new boolean[] {false, false, false, true, true, true}); + for (BreakDto b : r.breaks()) { + breaks.add(new String[] {b.type(), b.asset(), b.reference(), + Amounts.format(b.expected(), b.asset()), Amounts.format(b.actual(), b.asset()), + Amounts.format(b.difference(), b.asset())}, "BnTableRowBad", null, null); + } + if (r.breaks().isEmpty()) { + breaks.note(Lang.tr("Every control account agrees with its subledger.")); + } + body.add(breaks.component()); + done(body); + }, failed(body, () -> reconciliation(body)))); + } + + // ---------------------------------------------------------- period close + + private void periods(final Container body) { + Api.books().periods(Net.to((List periods) -> { + body.removeAll(); + body.add(Ui.muted(Lang.tr("Closing a period moves revenue and expense into " + + "retained earnings and locks every entry up to its date."))); + Table table = new Table(new String[] {"Period", "Closed through", "Closed by", + "Closed at", "Entries"}, new int[] {2, 4, 5, 4, 2}, + new boolean[] {false, false, false, false, true}); + for (PeriodDto p : periods) { + table.add(new String[] {"#" + p.seq(), when(p.closedThrough()), p.closedBy(), + when(p.closedAt()), String.valueOf(p.entries() == null ? 0 + : p.entries().size())}, null); + } + if (periods.isEmpty()) { + table.note(Lang.tr("No period has been closed yet.")); + } + body.add(table.component()); + if (admin) { + Button close = Ui.primary("Close the books through now"); + close.addActionListener(evt -> { + TextField password = password(); + Command go = new Command(Lang.tr("Close period")); + if (Dialog.show(Lang.tr("Close period"), BoxLayout.encloseY(password), go, + new Command(Lang.tr("Cancel"))) != go) { + return; + } + Api.admin().closePeriod(new ClosePeriodDto(System.currentTimeMillis(), + password.getText()), Net.to((PeriodDto p) -> periods(body), + (s, m) -> Dialog.show(Lang.tr("Not done"), Lang.tr(m), Lang.tr("OK"), + null))); + }); + body.add(close); + } + done(body); + }, failed(body, () -> periods(body)))); + } + + // ------------------------------------------------------------ approvals + + private void approvals(final Container body) { + Api.admin().approvals(Net.to((List list) -> { + body.removeAll(); + body.add(Ui.muted(Lang.tr("Maker-checker: an adjustment or a reversal raised by one " + + "administrator is posted only when another approves it."))); + Table table = new Table(new String[] {"Raised", "Type", "Maker", "Amount", "Memo", + "Status"}, new int[] {3, 3, 5, 3, 5, 3}, + new boolean[] {false, false, false, true, false, false}); + for (final ApprovalDto a : list) { + String amount = a.amount() == 0 ? (a.entryId() == null || a.entryId().length() == 0 + ? "" : shortId(a.entryId())) : Amounts.format(a.amount(), a.asset()); + boolean open = "PENDING".equals(a.status()); + table.add(new String[] {when(a.createdAt()), kindName(a.kind()), a.maker(), amount, + a.memo(), statusName(a.status())}, open ? "BnTableRowWarn" : "BnTableRow", + null, open ? () -> decide(body, a) : null); + } + if (list.isEmpty()) { + table.note(Lang.tr("Nothing waiting.")); + } + body.add(table.component()); + done(body); + }, failed(body, () -> approvals(body)))); + } + + private void decide(final Container body, final ApprovalDto a) { + TextField reason = Ui.field("Reason", TextField.ANY); + TextField password = password(); + Command approve = new Command(Lang.tr("Approve")); + Command reject = new Command(Lang.tr("Reject")); + Command picked = Dialog.show(Lang.tr("Decide"), BoxLayout.encloseY( + Ui.muted(Lang.tr("{0} raised by {1}", kindName(a.kind()), a.maker())), + Ui.muted(a.memo() == null ? "" : a.memo()), reason, password), + new Command[] {approve, reject, new Command(Lang.tr("Cancel"))}); + if (picked != approve && picked != reject) { + return; + } + DecisionDto decision = new DecisionDto(password.getText(), reason.getText()); + Net.Ok ok = done -> approvals(body); + Net.Failed no = (s, m) -> Dialog.show(Lang.tr("Not done"), Lang.tr(m), Lang.tr("OK"), null); + if (picked == approve) { + Api.admin().approve(a.id(), decision, Net.to(ok, no)); + } else { + Api.admin().reject(a.id(), decision, Net.to(ok, no)); + } + } + + // ---------------------------------------------------------------- audit + + private void chain(final Container body) { + Api.audit().chain(Net.to((final ChainReportDto c) -> Api.audit().events(null, "80", + Net.to((List events) -> { + body.removeAll(); + body.add(FlowLayout.encloseIn(new Label(c.intact() ? Lang.tr("Intact") + : Lang.tr("Broken at #{0}", String.valueOf(c.firstBrokenSeq())), + c.intact() ? "BnBadgeGood" : "BnBadgeBad"))); + Container facts = new Container(new GridLayout(wide ? 1 : 3, wide ? 3 : 1)); + facts.add(fact("Links checked", String.valueOf(c.checked()))); + facts.add(fact("Head", "#" + c.headSeq())); + facts.add(fact("Head hash", shortHash(c.headHash()))); + body.add(facts); + body.add(Ui.copyable(Lang.tr("Head hash"), c.headHash(), false)); + for (String problem : c.problems()) { + body.add(Ui.row(Lang.tr("Problem"), problem, null, null, null)); + } + body.add(Ui.muted(Lang.tr("Each event's hash covers its own fields and the " + + "hash before it, so changing any row breaks every link after it."))); + final Printout.Section rows = events(body, events, + c.intact() ? -1 : c.firstBrokenSeq()); + printButton(body, () -> new Printout(Lang.tr("Audit chain"), + System.currentTimeMillis()) + .fact(Lang.tr("Status"), c.intact() ? Lang.tr("Intact") + : Lang.tr("Broken at #{0}", String.valueOf(c.firstBrokenSeq()))) + .fact(Lang.tr("Links checked"), String.valueOf(c.checked())) + .fact(Lang.tr("Head"), "#" + c.headSeq()) + .fact(Lang.tr("Head hash"), c.headHash()) + .add(rows)); + done(body); + }, failed(body, () -> chain(body)))), failed(body, () -> chain(body)))); + } + + private Printout.Section events(Container body, List events, long broken) { + Table table = new Table(new String[] {"#", "Time", "Actor", "Action", "Subject", + "Previous hash", "Hash"}, new int[] {1, 3, 4, 4, 4, 3, 3}, + new boolean[] {true, false, false, false, false, false, false}); + for (AuditEventDto e : events) { + table.add(new String[] {String.valueOf(e.seq()), when(e.at()), e.actor(), e.action(), + e.subject(), shortHash(e.prevHash()), shortHash(e.hash())}, + e.seq() == broken ? "BnTableRowBad" : "BnTableRow", null, null); + } + if (events.isEmpty()) { + table.note(Lang.tr("No events.")); + } + body.add(table.component()); + return table.printable(); + } + + private void trail(final Container body, final String subject) { + body.removeAll(); + final TextField field = Ui.field("Entry id, account id or e-mail", TextField.ANY); + field.setText(subject); + Button find = Ui.primary("Show trail"); + Container search = new Container(new BorderLayout()); + search.add(BorderLayout.CENTER, field); + search.add(BorderLayout.EAST, find); + body.add(search); + final Container results = new Container(BoxLayout.y()); + body.add(results); + find.addActionListener(evt -> loadTrail(results, field.getText().trim())); + if (subject.length() > 0) { + loadTrail(results, subject); + } else { + results.add(Ui.muted(Lang.tr("Every event that names the subject, oldest first."))); + } + done(body); + } + + private void loadTrail(final Container results, final String subject) { + Ui.loading(results); + Api.audit().trail(subject, Net.to((List events) -> { + results.removeAll(); + final Printout.Section rows = events(results, events, -1); + printButton(results, () -> new Printout(Lang.tr("Audit trail"), + System.currentTimeMillis()) + .fact(Lang.tr("Subject"), subject) + .add(rows)); + done(results); + }, failed(results, () -> loadTrail(results, subject)))); + } + + private void accessLog(final Container body) { + Api.audit().accessLog("100", Net.to((List events) -> { + body.removeAll(); + body.add(Ui.muted(Lang.tr("Every read of the books and the audit log by staff is " + + "itself an audit event."))); + Table table = new Table(new String[] {"Time", "Staff member", "Action", "Subject", + "Address"}, new int[] {3, 5, 4, 5, 3}, + new boolean[] {false, false, false, false, false}); + for (AuditEventDto e : events) { + table.add(new String[] {when(e.at()), e.actor(), e.action(), e.subject(), + e.address()}, null); + } + if (events.isEmpty()) { + table.note(Lang.tr("No events.")); + } + body.add(table.component()); + final Printout.Section rows = table.printable(); + printButton(body, () -> new Printout(Lang.tr("Access log"), System.currentTimeMillis()) + .add(rows)); + done(body); + }, failed(body, () -> accessLog(body)))); + } + + // ------------------------------------------------------------- evidence + + private void evidence(final Container body) { + body.removeAll(); + body.add(Ui.muted(Lang.tr("A signed-off copy for an external auditor: the file, who " + + "made it and when, and its SHA-256 so it can be checked later."))); + final Container result = new Container(BoxLayout.y()); + String[] names = {"journal", "trial-balance", "audit-chain"}; + String[] labels = {"Journal", "Trial balance", "Audit chain"}; + for (int iter = 0; iter < names.length; iter++) { + final String name = names[iter]; + Button csv = Ui.secondary("CSV"); + csv.addActionListener(evt -> export(result, name, "csv")); + Button json = Ui.secondary("JSON"); + json.addActionListener(evt -> export(result, name, "json")); + Container row = new Container(new BorderLayout(), "BnRow"); + row.add(BorderLayout.CENTER, new Label(Lang.tr(labels[iter]), "BnRowTitle")); + row.add(BorderLayout.EAST, BoxLayout.encloseX(csv, json)); + body.add(row); + } + body.add(result); + done(body); + } + + private void export(final Container result, final String name, final String format) { + Ui.loading(result); + Api.audit().evidence(name, format, null, Net.to((EvidenceDto e) -> { + result.removeAll(); + String file = e.name() + "." + e.format(); + result.add(Ui.row(file, Lang.tr("Made by {0}, {1}", e.generatedBy(), + when(e.generatedAt())), Lang.tr("Saved"), "BnBadgeGood", null)); + result.add(Ui.copyable("SHA-256", e.sha256(), false)); + TextArea preview = new TextArea(head(e.content())); + preview.setEditable(false); + preview.setUIID("BnCode"); + preview.setRows(8); + result.add(preview); + printButton(result, () -> evidencePrintout(e)); + save(file, e.content()); + done(result); + }, failed(result, () -> export(result, name, format)))); + } + + /// Writes the file where the platform keeps the app's files and hands + /// it to the platform: a browser downloads it, a phone offers to share + /// or open it. + private static void save(String file, String content) { + FileSystemStorage fs = FileSystemStorage.getInstance(); + String path = fs.getAppHomePath() + file; + OutputStream out = null; + try { + out = fs.openOutputStream(path); + out.write(content.getBytes("UTF-8")); + } catch (Exception err) { + Ui.toast("Could not save the file."); + return; + } finally { + Util.cleanup(out); + } + CN.execute(path); + } + + /// An evidence export on paper: who made it, when, and its hash, then the + /// file itself line by line -- the hash is of the file, so the printed + /// copy can be checked against the saved one. + static Printout evidencePrintout(EvidenceDto e) { + Printout p = new Printout(Lang.tr("Evidence"), System.currentTimeMillis()) + .fact(Lang.tr("File"), e.name() + "." + e.format()) + .fact(Lang.tr("Made by"), e.generatedBy() + ", " + when(e.generatedAt())) + .fact("SHA-256", e.sha256()); + Printout.Section file = p.section(null, new String[] {e.name() + "." + e.format()}, + new int[] {1}, new boolean[] {false}); + String content = e.content() == null ? "" : e.content(); + int start = 0; + while (start < content.length()) { + int end = content.indexOf('\n', start); + if (end < 0) { + end = content.length(); + } + file.row(content.substring(start, end)); + start = end + 1; + } + return p; + } + + /// Adds a print button under a report, where this platform can print. + private static void printButton(Container body, Printout.Source source) { + Button print = Printout.button(source); + if (print != null) { + body.add(FlowLayout.encloseIn(print)); + } + } + + private static String head(String content) { + int lines = 0; + for (int iter = 0; iter < content.length(); iter++) { + if (content.charAt(iter) == '\n' && ++lines == 12) { + return content.substring(0, iter) + "\n..."; + } + } + return content; + } + + // -------------------------------------------------------------- helpers + + private static Component fact(String label, String value) { + Container box = new Container(BoxLayout.y(), "BnFact"); + box.add(new Label(Lang.tr(label), "BnFactLabel")); + Label v = new Label(value == null || value.length() == 0 ? "-" : value, "BnFactValue"); + v.setEndsWith3Points(true); + box.add(v); + return box; + } + + private static TextField password() { + TextField field = new TextField("", Lang.tr("Your password"), 20, TextArea.PASSWORD); + field.setUIID("BnField"); + return field; + } + + private static String dr(long units, String asset) { + return units == 0 ? "" : Amounts.format(units, asset); + } + + private static String owner(String owner) { + return owner == null || owner.length() == 0 || owner.startsWith("@") ? "" + : " (" + owner + ")"; + } + + static String shortId(String id) { + return id == null ? "" : id.length() <= 8 ? id : id.substring(0, 8); + } + + private static String shortHash(String hash) { + return hash == null ? "" : hash.length() <= 10 ? hash : hash.substring(0, 10); + } + + static String when(long millis) { + return millis <= 0 ? "" : new SimpleDateFormat("yyyy-MM-dd HH:mm").format(new Date(millis)); + } + + private static String side(String normal) { + return Lang.tr("DEBIT".equals(normal) ? "debit" : "credit"); + } + + static String className(String c) { + if ("ASSET".equals(c) || "ASSETS".equals(c)) { + return Lang.tr("Assets"); + } + if ("LIABILITY".equals(c) || "LIABILITIES".equals(c)) { + return Lang.tr("Liabilities"); + } + if ("EQUITY".equals(c)) { + return Lang.tr("Equity"); + } + if ("REVENUE".equals(c)) { + return Lang.tr("Revenue"); + } + if ("EXPENSE".equals(c) || "EXPENSES".equals(c)) { + return Lang.tr("Expenses"); + } + return c; + } + + static String statusName(String s) { + if ("PENDING".equals(s)) { + return Lang.tr("Pending"); + } + if ("EXECUTED".equals(s)) { + return Lang.tr("Approved"); + } + if ("REJECTED".equals(s)) { + return Lang.tr("Rejected"); + } + return s; + } + + static String kindName(String k) { + if (k == null) { + return ""; + } + if ("TRANSFER".equals(k)) { + return Lang.tr("Transfer"); + } + if ("DEPOSIT".equals(k)) { + return Lang.tr("Deposit"); + } + if ("WITHDRAWAL".equals(k)) { + return Lang.tr("Withdrawal"); + } + if ("TRADE".equals(k)) { + return Lang.tr("Trade"); + } + if ("ADJUSTMENT".equals(k)) { + return Lang.tr("Adjustment"); + } + if ("REVERSAL".equals(k)) { + return Lang.tr("Reversal"); + } + if ("CLOSING".equals(k)) { + return Lang.tr("Closing entry"); + } + if ("CAPITAL".equals(k)) { + return Lang.tr("Capital"); + } + if ("ORDER_HOLD".equals(k)) { + return Lang.tr("Order hold"); + } + if ("ORDER_RELEASE".equals(k)) { + return Lang.tr("Order release"); + } + if ("CRYPTO_OUT".equals(k)) { + return Lang.tr("Crypto withdrawal"); + } + return k; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/DepositForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/DepositForm.java new file mode 100644 index 00000000000..176cb91d52f --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/DepositForm.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.bastion.api.ChainTransferDto; +import com.codenameone.examples.bastion.api.DepositAddressDto; +import com.codenameone.examples.bastion.money.Amounts; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; + +import java.util.List; + +/// Receiving coins: the customer's own deposit address on the real network, +/// as text to copy and as a QR code a wallet scans, and every transfer that +/// crossed the chain with its status and transaction hash. +/// +/// The network's name is in large print above the address. Coins sent on the +/// wrong network -- a test network's address used on mainnet, or the reverse +/// -- arrive at an address the bank holds on neither, and are the most common +/// way customers lose them. +public final class DepositForm { + private static final String ASSET = "ETH"; + + private DepositForm() { + } + + public static void show(final Runnable back) { + Form form = Nav.form("Receive crypto", back); + final Container content = new Container(BoxLayout.y()); + form.add(content); + Ui.show(form, true); + load(content); + } + + private static void load(final Container content) { + Ui.loading(content); + Api.exchange().depositAddress(ASSET, Net.to((DepositAddressDto d) -> + Api.exchange().chainTransfers(Net.to((List moves) -> + fill(content, d, moves), + (status, message) -> fill(content, d, null))), + (status, message) -> Ui.failed(content, message, () -> load(content)))); + } + + private static void fill(Container content, DepositAddressDto d, List moves) { + content.removeAll(); + if (!d.live()) { + content.add(Ui.wrapped(Lang.tr("Coins move on a simulated chain in this server. " + + "Connect it to an Ethereum node to receive real Ether here."), "BnRowDetail")); + content.revalidate(); + return; + } + content.add(Ui.section("Network")); + content.add(Ui.row(Lang.tr(d.network()), Lang.tr("Send only {0} on this network to this " + + "address. Coins sent on another network are lost.", d.asset()), + Lang.tr("Chain {0}", String.valueOf(d.chainId())), "BnBadgeWarn", null)); + // EIP-681: a wallet that scans this fills in the address and refuses + // to send on a different chain. + String uri = "ethereum:" + d.address() + "@" + d.chainId(); + byte[] bytes = ascii(uri); + if (bytes.length <= QrCode.MAX_BYTES) { + Container qr = new Container(new FlowLayout(Container.CENTER)); + qr.add(new QrView(QrCode.encode(bytes))); + content.add(qr); + } + content.add(Ui.copyable(Lang.tr("Your {0} address", d.asset()), d.address(), false)); + content.add(Ui.wrapped(Lang.tr("Credited after {0} confirmations, usually a few minutes.", + String.valueOf(d.confirmations())), "BnRowDetail")); + content.add(Ui.section("On the chain")); + if (moves == null || moves.isEmpty()) { + content.add(Ui.empty("Nothing has crossed the chain yet.")); + } else { + for (ChainTransferDto t : moves) { + content.add(row(t)); + } + } + content.revalidate(); + } + + private static Container row(ChainTransferDto t) { + boolean in = "IN".equals(t.direction()); + String title = (in ? "+" : "-") + Amounts.format(t.amount(), t.asset()); + String detail = (in ? Lang.tr("Deposit") : Lang.tr("To {0}", t.address())) + + (t.txHash().length() > 0 ? "\n" + t.txHash() : "") + + (t.detail().length() > 0 ? "\n" + t.detail() : ""); + String style = "CONFIRMED".equals(t.status()) ? "BnBadgeGood" + : "FAILED".equals(t.status()) ? "BnBadgeBad" : "BnBadgeWarn"; + Container row = Ui.row(title, detail, Lang.tr(statusName(t.status())), style, + t.txHash().length() > 0 ? () -> Ui.copy(t.txHash(), false) : null); + return row; + } + + static String statusName(String status) { + if ("CONFIRMED".equals(status)) { + return "Final"; + } + if ("FAILED".equals(status)) { + return "Failed"; + } + if ("BROADCAST".equals(status)) { + return "Confirming"; + } + return "Sending"; + } + + private static byte[] ascii(String text) { + byte[] out = new byte[text.length()]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) text.charAt(iter); + } + return out; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/DeviceSetup.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/DeviceSetup.java new file mode 100644 index 00000000000..00f14c3b97d --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/DeviceSetup.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.ui.Command; +import com.codename1.ui.Dialog; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codenameone.examples.bastion.api.DeviceDto; +import com.codenameone.examples.bastion.api.UserDto; +import com.codenameone.examples.bastion.net.Net; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.security.DeviceKey; + +/// Registering this phone's signing key, from wherever it is asked for: +/// onboarding, the dashboard's banner, and a payment the server refused for +/// want of a signature. +/// +/// The server asks for two things before it trusts a new key: a sign-in a +/// few minutes old, and a second factor used in that sign-in -- an +/// authenticator code or a passkey -- since a stolen session and password +/// must not be able to add the device that then signs payments. A sign-in +/// made with the password alone is sent to Settings to set one up instead of +/// being refused by the server with nothing to do about it. +public final class DeviceSetup { + private DeviceSetup() { + } + + /// Whether this phone can sign payments for the signed-in account (a + /// key registered for another account does not count). + public static boolean registered() { + return DeviceKey.deviceId() != null && DeviceKey.ownedBy(Session.username()); + } + + /// Registers the device, asking for the password first when the account + /// needs it. `failed` is not called when the user cancels. + public static void register(final Runnable done, final Net.Failed failed) { + UserDto user = Session.user(); + if (user == null) { + failed.failed(401, "Sign in first"); + return; + } + String password = null; + if (!Session.signedInWith("mfa") && !Session.signedInWith("otp") + && !Session.signedInWith("hwk")) { + secondFactorFirst(); + return; + } + // The same rule as Devices on the server, which reads the token's own + // record of the sign-in, never whether an app is on the account now: a + // code sign-in (`otp`/`mfa`) or a user-verified passkey (`hwk` with + // `user`) is not asked for the password; a passkey the authenticator + // did not verify the user for (`hwk` alone) is. + if (!Session.signedInWith("mfa") && !Session.signedInWith("otp") + && !(Session.signedInWith("hwk") && Session.signedInWith("user"))) { + TextField field = new TextField("", Lang.tr("Password"), 20, TextArea.PASSWORD); + field.setUIID("BnField"); + Command ok = new Command(Lang.tr("Continue")); + if (Dialog.show(Lang.tr("Confirm it is you"), + // Enclosed, or the dialog stretches the one-line field to its + // whole body. + com.codename1.ui.layouts.BoxLayout.encloseY(field), ok, + new Command(Lang.tr("Cancel"))) != ok) { + return; + } + password = field.getText(); + } + DeviceKey.register(user.username(), password, (DeviceDto device) -> done.run(), + (status, message) -> { + if ("second-factor-add-device".equals(Net.refusalCode())) { + secondFactorFirst(); + } else { + failed.failed(status, message); + } + }); + } + + /// The server's answer to a password-only sign-in, said before asking it: + /// set up an authenticator app or a passkey, then sign in with it. + private static void secondFactorFirst() { + if (Dialog.show(Lang.tr("Set up a second factor"), + Lang.tr("Adding a device that signs payments needs a sign-in with an " + + "authenticator app or a passkey. Set one up in Settings, then sign " + + "in with it and register this device."), + Lang.tr("Open Settings"), Lang.tr("Not now"))) { + SettingsForm.show(null); + } + } + + /// A refusal that a registered device would have avoided, as the server + /// words it: no signature, or one from a device it does not know. + public static boolean needsDevice(int status, String message) { + String code = Net.refusalCode(); + return status == 403 && ("needs-device-signature".equals(code) + || "device-not-attested".equals(code)); + } + + /// After such a refusal: offers to register this device, and runs + /// `retry` once it is. + public static void offer(final Runnable retry, final Net.Failed failed) { + if (registered()) { + // A registered device that is still refused is untrusted (rooted, + // or not attested); registering again would not change that. + return; + } + if (Dialog.show(Lang.tr("Sign with this device"), + Lang.tr("Payments leaving your accounts are signed by a key that never leaves " + + "this phone. Register this device to continue."), + Lang.tr("Register"), Lang.tr("Not now"))) { + register(retry, failed); + } + } + + /// One try of a signed action. It signs afresh each time it runs -- a + /// signature's nonce is good once -- and reports a refusal to `failed`. + public interface Attempt { + void send(Net.Failed failed); + } + + /// Runs `attempt`; when the server refuses it for want of this device's + /// signature, offers to register the device and tries once more. Any + /// other refusal, or the second one, goes to `shown`. + public static void withDevice(final Attempt attempt, final Net.Failed shown) { + attempt.send((status, message) -> { + if (needsDevice(status, message) && !registered()) { + offer(() -> attempt.send(shown), shown); + } else { + shown.failed(status, message); + } + }); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/ExchangeForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/ExchangeForm.java new file mode 100644 index 00000000000..167694865fa --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/ExchangeForm.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.bastion.api.MarketDto; +import com.codenameone.examples.bastion.api.TradeDto; +import com.codenameone.examples.bastion.money.Amounts; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import java.util.List; + +/// The exchange's front page: every market with its index and last price, +/// and the user's own recent trades. +/// +/// The exchange trades on the same ledger as the bank. A filled order is a +/// ledger entry like a transfer -- the buyer's dollars and the seller's coins +/// change hands in one balanced posting, the fee to the bank's fee account -- +/// so a trade can never leave money in neither place. +public final class ExchangeForm { + private ExchangeForm() { + } + + public static void show(final Runnable back) { + Form form = Nav.form("Exchange", back); + final Container content = new Container(BoxLayout.y()); + form.add(content); + Ui.show(form, true); + load(content, back); + } + + private static void load(final Container content, final Runnable back) { + Ui.loading(content); + Api.exchange().markets(Net.to((List markets) -> + Api.exchange().trades(Net.to((List trades) -> + fill(content, markets, trades, back), + (status, message) -> fill(content, markets, null, back))), + (status, message) -> Ui.failed(content, message, () -> load(content, back)))); + } + + private static void fill(final Container content, List markets, + List trades, final Runnable back) { + content.removeAll(); + content.add(Ui.section("Markets")); + if (markets.isEmpty()) { + content.add(Ui.empty("No markets are open.")); + } + for (final MarketDto market : markets) { + long shown = market.lastPrice() > 0 ? market.lastPrice() : market.indexPrice(); + content.add(Ui.row(market.base() + " / " + market.quote(), + Lang.tr("Index {0}", Amounts.format(market.indexPrice(), market.quote())), + Amounts.format(shown, market.quote()), "BnRowValue", + () -> MarketForm.show(market, () -> show(back)))); + } + if (trades != null) { + content.add(Ui.section("Your trades")); + if (trades.isEmpty()) { + content.add(Ui.empty("You have not traded yet.")); + } + for (TradeDto trade : trades) { + content.add(MarketForm.tradeRow(trade, markets)); + } + } + content.revalidate(); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/HomeForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/HomeForm.java new file mode 100644 index 00000000000..7ef5b133f3e --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/HomeForm.java @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Command; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.spinner.Picker; +import com.codenameone.examples.bastion.api.AccountDto; +import com.codenameone.examples.bastion.api.AllocationDto; +import com.codenameone.examples.bastion.api.BalancePointDto; +import com.codenameone.examples.bastion.api.MarketDto; +import com.codenameone.examples.bastion.api.OpenAccountDto; +import com.codenameone.examples.bastion.api.UserDto; +import com.codenameone.examples.bastion.money.Amounts; +import com.codenameone.examples.bastion.money.Assets; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import com.codenameone.examples.bastion.net.Session; +import java.util.ArrayList; +import java.util.List; + +/// The dashboard: every account with its balance in its own asset, and the +/// total of all of them in dollars. +/// +/// The total is a valuation, not a balance. A crypto holding is priced at its +/// market's index price, rounded down ([Amounts#value]); an asset with no +/// dollar market (euros, here) is left out of the total and the screen says +/// so, rather than converting at a rate the bank never quoted. +public final class HomeForm { + private HomeForm() { + } + + /// The greeting uses the first name: a full name does not fit a phone's + /// title line. + private static String firstName(String name) { + String trimmed = name.trim(); + int space = trimmed.indexOf(' '); + return space > 0 ? trimmed.substring(0, space) : trimmed; + } + + public static void show() { + UserDto user = Session.user(); + Form form = Nav.form("Bastion", null); + String name = user == null ? null : user.displayName(); + form.add(Ui.title(name == null ? Lang.tr("Welcome") : Lang.tr("Hello, {0}", firstName(name)))); + final Container content = new Container(BoxLayout.y()); + form.add(content); + Ui.show(form, true); + load(content); + } + + private static void load(final Container content) { + Ui.loading(content); + Assets.load(() -> Api.banking().accounts(Net.to((List accounts) -> + Api.exchange().markets(Net.to((List markets) -> + fill(content, accounts, markets), + // Without prices the balances are still worth showing. + (status, message) -> fill(content, accounts, new ArrayList()))), + (status, message) -> Ui.failed(content, message, () -> load(content))))); + } + + private static void fill(final Container content, List accounts, + List markets) { + content.removeAll(); + if (!DeviceSetup.registered()) { + content.add(deviceBanner(content)); + } + long total = 0; + List unpriced = new ArrayList(); + for (AccountDto account : accounts) { + if (Assets.REPORTING.equals(account.asset())) { + total += account.balance(); + continue; + } + long price = price(markets, account.asset(), Assets.REPORTING); + if (price > 0) { + total += Amounts.value(account.balance(), Assets.decimals(account.asset()), price); + } else if (account.balance() != 0 && !unpriced.contains(account.asset())) { + unpriced.add(account.asset()); + } + } + Container hero = new Container(BoxLayout.y(), "BnHero"); + hero.add(new Label(Lang.tr("Total balance"), "BnHeroLabel")); + hero.add(new Label(Amounts.format(total, Assets.REPORTING), "BnHeroAmount")); + if (!unpriced.isEmpty()) { + SpanLabel note = new SpanLabel(Lang.tr("Not counted, no dollar price: {0}", + join(unpriced))); + note.setTextUIID("BnHeroNote"); + hero.add(note); + } + content.add(hero); + + Button move = Ui.primary("Move money"); + move.addActionListener(evt -> TransferForm.show(null, false, HomeForm::show)); + Button pay = Ui.secondary("Pay someone"); + pay.addActionListener(evt -> TransferForm.show(null, true, HomeForm::show)); + Button trade = Ui.secondary("Trade"); + trade.addActionListener(evt -> ExchangeForm.show(HomeForm::show)); + content.add(Ui.across(move, pay, trade)); + + Container insights = new Container(BoxLayout.y()); + insights.setName("insights"); + content.add(insights); + insights(insights, accounts); + + content.add(Ui.section("Accounts")); + if (accounts.isEmpty()) { + content.add(Ui.empty("You have no accounts yet.")); + } + for (final AccountDto account : accounts) { + String detail = kindName(account.kind()) + " - " + account.asset() + + (account.frozen() ? " - " + Lang.tr("Frozen") : ""); + content.add(Ui.row(account.name(), detail, + Amounts.format(account.balance(), account.asset()), + account.frozen() ? "BnRowValueMuted" : "BnRowValue", + () -> AccountForm.show(account.id(), HomeForm::show))); + } + Button open = Ui.secondary("Open an account"); + open.addActionListener(evt -> openAccount(content)); + content.add(open); + content.revalidate(); + } + + /// The dollar balance over the last 30 days, the sum of every dollar + /// account's end-of-day balance, and how the money is spread across + /// assets. Both are extras: when either cannot load, the screen goes on + /// without it. + private static void insights(final Container box, List accounts) { + final List dollars = new ArrayList(); + for (AccountDto account : accounts) { + if (Assets.REPORTING.equals(account.asset())) { + dollars.add(account.id()); + } + } + final Container history = new Container(BoxLayout.y()); + final Container allocation = new Container(BoxLayout.y()); + box.addAll(history, allocation); + if (!dollars.isEmpty()) { + sumHistory(dollars, 0, null, null, history); + } + Api.insights().allocation(Net.to((List parts) -> { + long held = 0; + for (AllocationDto part : parts) { + held += Math.max(part.usdCents(), 0); + } + // Nothing held yet: a ring of no slices says nothing. + if (held <= 0) { + return; + } + String[] labels = new String[parts.size()]; + double[] values = new double[labels.length]; + String[] shares = new String[labels.length]; + for (int iter = 0; iter < labels.length; iter++) { + AllocationDto part = parts.get(iter); + labels[iter] = part.asset(); + values[iter] = Math.max(part.usdCents(), 0) / 100.0; + shares[iter] = (part.basisPoints() / 100) + "." + (part.basisPoints() % 100 / 10) + + "%"; + } + allocation.add(Ui.section("Where your money is")); + allocation.add(Charts.donut("allocation-chart", labels, values, shares)); + box.getComponentForm().revalidate(); + }, (status, message) -> { })); + } + + private static void sumHistory(final List ids, final int at, final long[] days, + final double[] total, final Container history) { + if (at == ids.size()) { + history.add(Ui.section("Cash in dollar accounts, 30 days")); + history.add(Charts.line("balance-chart", days, total, "$")); + if (history.getComponentForm() != null) { + history.getComponentForm().revalidate(); + } + return; + } + Api.insights().balanceHistory(ids.get(at), "30", Net.to((List points) -> { + long[] d = days == null ? new long[points.size()] : days; + double[] t = total == null ? new double[points.size()] : total; + for (int iter = 0; iter < Math.min(points.size(), d.length); iter++) { + d[iter] = points.get(iter).day(); + t[iter] += points.get(iter).balance() / 100.0; + } + sumHistory(ids, at + 1, d, t, history); + }, (status, message) -> { })); + } + + /// The price of one whole `base` in `quote` units, or 0 when no market + /// quotes it. + static long price(List markets, String base, String quote) { + for (MarketDto market : markets) { + if (market.base().equals(base) && market.quote().equals(quote)) { + return market.indexPrice(); + } + } + return 0; + } + + static String kindName(String kind) { + if ("CHECKING".equals(kind)) { + return Lang.tr("Checking"); + } + if ("SAVINGS".equals(kind)) { + return Lang.tr("Savings"); + } + if ("WALLET".equals(kind)) { + return Lang.tr("Wallet"); + } + return kind; + } + + private static Container deviceBanner(final Container content) { + Container banner = new Container(BoxLayout.y(), "BnBanner"); + banner.add(Ui.wrapped(Lang.tr("This device cannot sign payments yet"), "BnBannerTitle")); + SpanLabel why = new SpanLabel(Lang.tr("Payments to other people and crypto withdrawals " + + "need a key that lives only on this phone.")); + why.setTextUIID("BnBannerText"); + banner.add(why); + final SpanLabel error = Ui.error(); + Button register = Ui.primary("Register this device"); + register.addActionListener(evt -> DeviceSetup.register(() -> load(content), + (status, message) -> Ui.showError(error, Lang.tr(message)))); + banner.addAll(error, register); + return banner; + } + + /// Opens another account: fiat in a checking or savings account, a coin + /// in a wallet -- the server's rule, offered here as the only choices. + private static void openAccount(final Container content) { + Assets.Asset[] all = Assets.all(); + List labels = new ArrayList(); + final List choices = new ArrayList(); + for (Assets.Asset asset : all) { + if (asset.isCrypto()) { + labels.add(Lang.tr("{0} wallet", asset.name)); + choices.add(new String[] {asset.code, "WALLET"}); + } else { + labels.add(Lang.tr("{0} checking", asset.name)); + choices.add(new String[] {asset.code, "CHECKING"}); + labels.add(Lang.tr("{0} savings", asset.name)); + choices.add(new String[] {asset.code, "SAVINGS"}); + } + } + final String[] names = labels.toArray(new String[labels.size()]); + final String[] ids = new String[names.length]; + for (int iter = 0; iter < ids.length; iter++) { + ids[iter] = choices.get(iter)[0] + "/" + choices.get(iter)[1]; + } + // Most people's second account is savings in their own currency. + Picker which = Ui.picker(ids, names, Assets.REPORTING + "/SAVINGS"); + TextField name = Ui.field("Name (optional)", TextField.ANY); + Container body = new Container(BoxLayout.y()); + body.addAll(which, name); + Command open = new Command(Lang.tr("Open")); + if (Dialog.show(Lang.tr("Open an account"), body, open, new Command(Lang.tr("Cancel"))) + != open) { + return; + } + int index = Ui.indexOf(ids, Ui.pickedId(which)); + if (index < 0) { + return; + } + String[] choice = choices.get(index); + String title = name.getText().trim().length() > 0 ? name.getText().trim() : names[index]; + Api.banking().open(new OpenAccountDto(choice[0], choice[1], title), + Net.to((AccountDto account) -> load(content), + (status, message) -> Dialog.show(Lang.tr("Not opened"), Lang.tr(message), + Lang.tr("OK"), null))); + } + + private static String join(List parts) { + StringBuilder out = new StringBuilder(); + for (String part : parts) { + if (out.length() > 0) { + out.append(", "); + } + out.append(part); + } + return out.toString(); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Lang.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Lang.java new file mode 100644 index 00000000000..07f6f636d41 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Lang.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.l10n.L10NManager; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; +import com.codename1.io.Preferences; +import java.util.HashMap; +import java.util.Map; + +/// The language the app speaks. +/// +/// The translations are the `Bundle_.properties` files in +/// `src/main/l10n`, which the build packs into the theme. One of them at a +/// time is installed as the `UIManager`'s bundle, and from then on every +/// label, button, title and hint looks its text up there as it is set: the +/// English text in the source is the key, and a text with no entry is shown as +/// it is. So a screen written in English is translated without saying so. +/// +/// What a component cannot do by itself is a sentence with something put into +/// it -- "Hello, Ana". Those go through [#tr(String, Object...)], with the +/// whole sentence as the key and `{0}` where the name goes, because where it +/// goes is the translator's decision. +/// +/// To add a string: write it in English in the code and add its line to +/// `Bundle_en.properties`, which lists every key (`BundleTest` checks it). To +/// add a language: copy that file to `Bundle_.properties`, translate the +/// values, and add the code to [#CODES]. +public final class Lang { + /// The languages there is a translation for, English (the source) first. + public static final String[] CODES = {"en", "es", "fr", "de", "he"}; + + private static final String BUNDLE = "Bundle"; + /// The entry each file names its own language in, in that language. + private static final String NAME_KEY = "language.name"; + + private Lang() { + } + + /// The language in use: the one chosen in Settings, else the device's when + /// there is a translation for it, else English. + public static String current() { + String chosen = known(Preferences.get("bastion.language", "")); + if (chosen != null) { + return chosen; + } + String device = known(L10NManager.getInstance().getLanguage()); + return device == null ? CODES[0] : device; + } + + /// Installs the translation for [#current()]. A screen built after this is + /// in the new language, and laid out right to left when the language is + /// written that way; one already built keeps the words it was built with. + public static void apply() { + Map bundle = new HashMap(); + // Always said, one way or the other: a bundle that is silent about the + // direction leaves the one before it in force. + bundle.put("@rtl", "false"); + Map translated = table(current()); + if (translated != null) { + bundle.putAll(translated); + } + UIManager.getInstance().setBundle(bundle); + } + + /// Speaks `code` from now on (Settings), and installs it. + public static void choose(String code) { + Preferences.set("bastion.language", code); + apply(); + } + + /// What `code`'s language calls itself. + public static String name(String code) { + Map translated = table(code); + String name = translated == null ? null : translated.get(NAME_KEY); + return name == null || name.length() == 0 ? code : name; + } + + public static String tr(String text) { + return UIManager.getInstance().localize(text, text); + } + + /// Translates `pattern` and puts `values` where it says `{0}`, `{1}`... + public static String tr(String pattern, Object... values) { + String text = tr(pattern); + for (int iter = 0; iter < values.length; iter++) { + String mark = "{" + iter + "}"; + String value = String.valueOf(values[iter]); + int at = text.indexOf(mark); + while (at >= 0) { + text = text.substring(0, at) + value + text.substring(at + mark.length()); + at = text.indexOf(mark, at + value.length()); + } + } + return text; + } + + /// The code among [#CODES] that `language` is, or null. A device reports + /// its language with or without a country (`es`, `es_MX`) and Hebrew under + /// either of two codes; this is compared as the ASCII it is, since folding + /// the case would follow the very language being worked out. + private static String known(String language) { + if (language == null || language.length() < 2) { + return null; + } + if (language.regionMatches(true, 0, "iw", 0, 2)) { + return "he"; + } + for (String code : CODES) { + if (language.regionMatches(true, 0, code, 0, 2)) { + return code; + } + } + return null; + } + + private static Map table(String code) { + Resources theme = Resources.getGlobalResources(); + return theme == null ? null : theme.getL10N(BUNDLE, code); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/LockForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/LockForm.java new file mode 100644 index 00000000000..ef830f5a31d --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/LockForm.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codenameone.examples.bastion.security.Keys; +import com.codenameone.examples.bastion.security.Lock; + +/// What is on screen while the app is locked: nothing of the account. +/// +/// The ways back in, strongest first: a biometric (when the user turned it +/// on and the OS still holds that copy of the key), then the app PIN, then +/// signing in again -- which gives up the keys on this device. +public final class LockForm { + private LockForm() { + } + + /// `signInAgain` is the way out when there is no biometric or PIN to ask + /// for, or the user would rather. + public static void show(final Runnable signInAgain) { + Container form = Ui.landing("Locked", "Bastion is locked", null); + final SpanLabel error = Ui.error(); + form.add(error); + if (biometric()) { + Button unlock = Ui.primary("Unlock"); + unlock.addActionListener(evt -> ask(signInAgain, error)); + form.add(unlock); + } + if (Keys.pinEnabled()) { + final TextField pin = Ui.field("PIN", TextArea.NUMERIC | TextArea.PASSWORD); + pin.setMaxSize(Keys.MAX_PIN); + Button withPin = biometric() ? Ui.secondary("Unlock with PIN") + : Ui.primary("Unlock with PIN"); + withPin.addActionListener(evt -> tryPin(pin, withPin, error, signInAgain)); + pin.setDoneListener(evt -> tryPin(pin, withPin, error, signInAgain)); + form.addAll(pin, withPin); + } + if (!Keys.protectedByUser()) { + form.add(Ui.muted(Lang.tr("No biometric or PIN is set up on this device, so the " + + "way back in is your password."))); + } + Button again = Keys.protectedByUser() ? Ui.secondary("Sign in again") + : Ui.primary("Sign in again"); + again.addActionListener(evt -> { + if (!Keys.protectedByUser() || Ui.confirm("Sign in again", + Lang.tr("Signing in again sets this device up from the start: its " + + "payment key, biometric and PIN are removed."), + "Sign in again")) { + signInAgain.run(); + } + }); + form.add(again); + // Locked screens are sensitive too: the last frame before the lock + // must not survive in the recent-apps list. + Ui.showLockScreen(form.getComponentForm()); + if (biometric()) { + ask(signInAgain, error); + } + } + + /// Whether to offer the biometric: the device has one and the user + /// stored the biometric copy of the key. + private static boolean biometric() { + return Lock.biometricsAvailable() && Keys.biometricEnabled(); + } + + private static void ask(final Runnable signInAgain, final SpanLabel error) { + Lock.unlockWithBiometric(gone -> { + if (gone != null && gone.booleanValue()) { + // The OS revoked that copy (a biometric was enrolled since): + // show the screen again without the biometric. + show(signInAgain); + return; + } + Ui.showError(error, Keys.pinEnabled() + ? Lang.tr("Not recognised. Try again, or use your PIN.") + : Lang.tr("Not recognised. Try again.")); + }); + } + + /// One PIN attempt at a time: the field and the button stay disabled + /// until the vault answers. Two attempts in flight would read the same + /// failure count, and each record one -- so a guesser typing fast got + /// more than [Keys#PIN_ATTEMPTS] tries before the wipe. + static void tryPin(final TextField pin, final Button withPin, final SpanLabel error, + final Runnable signInAgain) { + if (!pin.isEnabled()) { + return; + } + String typed = pin.getText(); + pin.setText(""); + if (typed.length() < Keys.MIN_PIN) { + Ui.showError(error, Lang.tr("Enter your PIN of at least {0} digits.", + String.valueOf(Keys.MIN_PIN))); + return; + } + pin.setEnabled(false); + withPin.setEnabled(false); + final Runnable again = () -> { + pin.setEnabled(true); + withPin.setEnabled(true); + }; + Lock.unlockWithPin(typed, new Keys.PinResult() { + @Override + public void unlocked() { + } + + @Override + public void wrong(int left) { + again.run(); + Ui.showError(error, left == 1 + ? Lang.tr("Wrong PIN. One try left before the keys on this device are erased.") + : Lang.tr("Wrong PIN. {0} tries left.", String.valueOf(left))); + } + + @Override + public void wiped() { + Ui.toast("Too many wrong PINs. The keys on this device were erased."); + signInAgain.run(); + } + + @Override + public void failed(String why) { + again.run(); + Ui.showError(error, Lang.tr(why)); + } + }); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/MarketForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/MarketForm.java new file mode 100644 index 00000000000..12c2ce58a37 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/MarketForm.java @@ -0,0 +1,363 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.bastion.api.BookDto; +import com.codenameone.examples.bastion.api.CandleDto; +import com.codenameone.examples.bastion.api.LevelDto; +import com.codenameone.examples.bastion.api.MarketDto; +import com.codenameone.examples.bastion.api.NewOrderDto; +import com.codenameone.examples.bastion.api.OrderDto; +import com.codenameone.examples.bastion.api.TickerDto; +import com.codenameone.examples.bastion.api.TradeDto; +import com.codenameone.examples.bastion.money.Amounts; +import com.codenameone.examples.bastion.money.Assets; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import com.codenameone.examples.bastion.security.RequestSigner; +import java.util.List; + +/// One market: the ticker, the order book, an order ticket, and the user's +/// open orders in it. +/// +/// Prices are quote units per **one whole** base unit (cents per bitcoin); +/// quantities are base units (satoshi) and must be a whole number of the +/// market's lot. The ticket reads both as the user types them, in the +/// asset's own decimals, and refuses a quantity that is not whole lots before +/// the server has to. +/// +/// The four shapes of order are the server's: +/// +/// - a **limit** buy or sell names a price and a quantity, and rests on the +/// book for whatever does not fill at once; +/// - a **market buy** names only a budget in the quote asset, and buys whole +/// lots from the best asks while the budget lasts; +/// - a **market sell** names only a quantity, sold into the best bids. +/// +/// An order is placed with an idempotency key, so a retry after a lost +/// answer cannot place it twice. It needs no device signature: an order +/// moves money only between the user's own accounts, at a price on the book. +/// +/// The book and the orders refresh every few seconds while the screen is +/// up; the timer belongs to the form and stops with it. +public final class MarketForm { + private static final int DEPTH = 6; + private static final int REFRESH_MILLIS = 4000; + + private MarketForm() { + } + + public static void show(final MarketDto market, final Runnable back) { + Form form = Nav.form(market.base() + " / " + market.quote(), back); + final Label ticker = new Label(" ", "BnHeroAmount"); + final Label spread = new Label(" ", "BnHeroNote"); + Container hero = new Container(BoxLayout.y(), "BnHero"); + hero.addAll(new Label(Lang.tr("Last price"), "BnHeroLabel"), ticker, spread); + form.add(hero); + final Container chart = new Container(BoxLayout.y()); + form.add(chart); + candles(market, chart); + final Container book = new Container(BoxLayout.y()); + final Container orders = new Container(BoxLayout.y()); + form.add(Ui.section("Order book")); + form.add(book); + form.add(Ui.section("Place an order")); + form.add(ticket(market, () -> refresh(market, ticker, spread, book, orders))); + form.add(Ui.section("Your open orders")); + form.add(orders); + Ui.show(form, true); + Runnable tick = () -> refresh(market, ticker, spread, book, orders); + tick.run(); + UITimer.timer(REFRESH_MILLIS, true, form, tick); + } + + private static void refresh(final MarketDto market, final Label ticker, final Label spread, + final Container book, final Container orders) { + Net.Failed quiet = (status, message) -> { }; + Api.exchange().ticker(market.id(), Net.to((TickerDto t) -> { + long last = t.lastPrice() > 0 ? t.lastPrice() : t.indexPrice(); + ticker.setText(Amounts.format(last, market.quote())); + spread.setText(Lang.tr("Bid {0} - Ask {1}", price(t.bestBid(), market), + price(t.bestAsk(), market))); + ticker.getParent().revalidate(); + }, quiet)); + Api.exchange().book(market.id(), Net.to((BookDto b) -> fillBook(book, b, market), quiet)); + Api.exchange().openOrders(Net.to((List open) -> fillOrders(orders, open, market, + () -> refresh(market, ticker, spread, book, orders)), quiet)); + } + + /// The last day in hourly candles, with the volume traded each hour + /// under them. Loaded once: the candles move by the hour, the book above + /// by the second. + private static void candles(final MarketDto market, final Container box) { + Api.insights().candles(market.id(), null, "24", Net.to((List list) -> { + if (list.size() < 2) { + return; + } + double q = Amounts.pow10(Assets.decimals(market.quote())); + double b = Amounts.pow10(Assets.decimals(market.base())); + int n = list.size(); + long[] starts = new long[n]; + double[] open = new double[n]; + double[] high = new double[n]; + double[] low = new double[n]; + double[] close = new double[n]; + double[] volume = new double[n]; + for (int iter = 0; iter < n; iter++) { + CandleDto c = list.get(iter); + starts[iter] = c.start(); + open[iter] = c.open() / q; + high[iter] = c.high() / q; + low[iter] = c.low() / q; + close[iter] = c.close() / q; + volume[iter] = c.volume() / b; + } + box.add(Ui.section("Price, 24 hours")); + box.add(Charts.candles("price-chart", starts, open, high, low, close)); + box.add(new Label(Lang.tr("Volume ({0})", market.base()), "BnChartCaption")); + box.add(Charts.bars("volume-chart", starts, volume, "BnChartGrid")); + if (box.getComponentForm() != null) { + box.getComponentForm().revalidate(); + } + }, (status, message) -> { })); + } + + private static String price(long units, MarketDto market) { + return units > 0 ? Amounts.format(units, market.quote()) : "-"; + } + + /// Asks on top, highest first down to the best; bids under, best first -- + /// the spread sits in the middle where the eye goes. + private static void fillBook(Container book, BookDto b, MarketDto market) { + book.removeAll(); + List asks = b.asks(); + List bids = b.bids(); + if (asks.isEmpty() && bids.isEmpty()) { + book.add(Ui.empty("The book is empty.")); + } + int askCount = Math.min(DEPTH, asks.size()); + for (int iter = askCount - 1; iter >= 0; iter--) { + book.add(level(asks.get(iter), market, "BnAsk")); + } + for (int iter = 0; iter < Math.min(DEPTH, bids.size()); iter++) { + book.add(level(bids.get(iter), market, "BnBid")); + } + book.revalidate(); + } + + private static Container level(LevelDto level, MarketDto market, String uiid) { + Container row = new Container(new GridLayout(1, 2), "BnBookRow"); + row.add(new Label(Amounts.format(level.price(), market.quote()), uiid)); + Label qty = new Label(Amounts.format(level.quantity(), market.base()), "BnBookQty"); + row.add(qty); + return row; + } + + private static void fillOrders(final Container orders, List open, + final MarketDto market, final Runnable changed) { + orders.removeAll(); + boolean any = false; + for (final OrderDto order : open) { + if (!order.market().equals(market.id())) { + continue; + } + any = true; + String what = Lang.tr("BUY".equals(order.side()) ? "Buy" : "Sell") + " " + + Amounts.format(order.quantity(), market.base()); + String detail = Lang.tr("at {0}, {1} filled", price(order.price(), market), + Amounts.format(order.filled(), market.base())); + Container row = new Container(new BorderLayout(), "BnRow"); + Container words = new Container(BoxLayout.y()); + words.addAll(new Label(what, "BnRowTitle"), new Label(detail, "BnRowDetail")); + row.add(BorderLayout.CENTER, words); + final Button cancel = new Button(Lang.tr("Cancel"), "BnLink"); + cancel.addActionListener(evt -> { + cancel.setEnabled(false); + Api.exchange().cancel(order.id(), Net.to((OrderDto done) -> changed.run(), + (status, message) -> { + cancel.setEnabled(true); + Ui.toast(message); + })); + }); + row.add(BorderLayout.EAST, cancel); + orders.add(row); + } + if (!any) { + orders.add(Ui.empty("No open orders in this market.")); + } + orders.revalidate(); + } + + private static Container ticket(final MarketDto market, final Runnable placed) { + final Container ticket = new Container(BoxLayout.y(), "BnPanel"); + final boolean[] buy = {true}; + final boolean[] limit = {true}; + final TextField price = Ui.field(Lang.tr("Price in {0}", market.quote()), TextField.DECIMAL); + final TextField quantity = Ui.field(Lang.tr("Amount of {0}", market.base()), TextField.DECIMAL); + final TextField budget = Ui.field(Lang.tr("Spend, in {0}", market.quote()), TextField.DECIMAL); + final Label hint = new Label(Lang.tr("In steps of {0}", + Amounts.format(market.lot(), market.base())), "BnMuted"); + final Container fields = new Container(BoxLayout.y()); + final Runnable layout = () -> { + fields.removeAll(); + if (limit[0]) { + fields.addAll(price, quantity, hint); + } else if (buy[0]) { + fields.add(budget); + } else { + fields.addAll(quantity, hint); + } + fields.revalidate(); + }; + ticket.add(Ui.segments(new String[] {"Buy", "Sell"}, 0, index -> { + buy[0] = index == 0; + layout.run(); + })); + ticket.add(Ui.segments(new String[] {"Limit", "Market"}, 0, index -> { + limit[0] = index == 0; + layout.run(); + })); + final SpanLabel error = Ui.error(); + final Button place = Ui.primary("Place order"); + ticket.addAll(fields, error, place); + final NewOrderDto[] tried = new NewOrderDto[1]; + final String[] key = new String[1]; + place.addActionListener(evt -> { + Ui.showError(error, null); + NewOrderDto order = order(market, buy[0], limit[0], price, quantity, budget, error); + if (order == null) { + return; + } + if (!Ui.confirm("Confirm order", describe(order, market), "Place")) { + return; + } + if (tried[0] == null || !tried[0].equals(order)) { + tried[0] = order; + key[0] = RequestSigner.idempotencyKey(); + } + place.setEnabled(false); + // Signed by this device, as a payment is: an order moves value. + final String k = key[0]; + DeviceSetup.withDevice(retry -> Api.exchange().place(k, + RequestSigner.sign("POST", "/api/exchange/orders", k, order), order, + Net.to((OrderDto done) -> { + place.setEnabled(true); + tried[0] = null; + quantity.setText(""); + budget.setText(""); + Ui.toast(Lang.tr("Order {0}", status(done.status()))); + placed.run(); + }, retry)), (status, message) -> { + place.setEnabled(true); + Ui.showError(error, Lang.tr(message)); + }); + }); + return ticket; + } + + /// The order the ticket describes, or null with the reason shown. + static NewOrderDto order(MarketDto market, boolean buy, boolean limit, TextField price, + TextField quantity, TextField budget, SpanLabel error) { + String side = buy ? "BUY" : "SELL"; + if (!limit && buy) { + long spend = Amounts.parse(budget.getText(), market.quote()); + if (spend <= 0) { + Ui.showError(error, Lang.tr("Enter how much to spend.")); + return null; + } + return new NewOrderDto(market.id(), side, "MARKET", 0, 0, spend); + } + long qty = Amounts.parse(quantity.getText(), market.base()); + if (qty <= 0 || qty % market.lot() != 0) { + Ui.showError(error, Lang.tr("Enter an amount in steps of {0}.", + Amounts.format(market.lot(), market.base()))); + return null; + } + if (!limit) { + return new NewOrderDto(market.id(), side, "MARKET", 0, qty, 0); + } + long at = Amounts.parse(price.getText(), market.quote()); + if (at <= 0) { + Ui.showError(error, Lang.tr("Enter a price.")); + return null; + } + return new NewOrderDto(market.id(), side, "LIMIT", at, qty, 0); + } + + private static String describe(NewOrderDto order, MarketDto market) { + if (order.budget() > 0) { + return Lang.tr("Buy {0} at the best prices, spending up to {1}?", market.base(), + Amounts.format(order.budget(), market.quote())); + } + String amount = Amounts.format(order.quantity(), market.base()); + if (order.price() == 0) { + return Lang.tr("Sell {0} at the best prices?", amount); + } + // What the order is worth at its own price, rounded down: an estimate, + // since the fee and the fills decide the rest. + String worth = Amounts.format(Amounts.value(order.quantity(), + Assets.decimals(market.base()), order.price()), market.quote()); + return Lang.tr("BUY".equals(order.side()) ? "Buy {0} at {1} (about {2})?" + : "Sell {0} at {1} (about {2})?", amount, + Amounts.format(order.price(), market.quote()), worth); + } + + private static String status(String status) { + if ("FILLED".equals(status)) { + return Lang.tr("filled"); + } + if ("OPEN".equals(status)) { + return Lang.tr("on the book"); + } + if ("CANCELLED".equals(status)) { + // A market order whose remainder found no one to trade with. + return Lang.tr("ended, the rest unfilled"); + } + return status; + } + + /// A trade of the user's, for the exchange's front page. + static Container tradeRow(TradeDto trade, List markets) { + String base = trade.market(); + String quote = "USD"; + for (MarketDto market : markets) { + if (market.id().equals(trade.market())) { + base = market.base(); + quote = market.quote(); + } + } + boolean bought = "BUY".equals(trade.side()); + String title = Lang.tr(bought ? "Bought {0}" : "Sold {0}", + Amounts.format(trade.quantity(), base)); + String detail = Lang.tr("at {0}, fee {1}", Amounts.format(trade.price(), quote), + Amounts.format(trade.fee(), quote)) + " - " + Ui.when(trade.createdAt()); + return Ui.row(title, detail, (bought ? "-" : "+") + Amounts.format(trade.quote(), quote), + bought ? "BnAmountOut" : "BnAmountIn", null); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Nav.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Nav.java new file mode 100644 index 00000000000..b45e5bfe658 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Nav.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.Toolbar; +import com.codenameone.examples.bastion.api.UserDto; +import com.codenameone.examples.bastion.net.Session; + +/// The way around the app: the side menu every signed-in screen has, and the +/// screen each entry leads to. +/// +/// The admin entry is shown only to an account with the role, and that is a +/// courtesy, not the protection: the server refuses `/api/admin/**` to anyone +/// else whatever the app shows. +public final class Nav { + private static Runnable signedOut; + + private Nav() { + } + + /// What to show once the user has signed out, or been signed out. + public static void install(Runnable whenSignedOut) { + signedOut = whenSignedOut; + } + + /// Adds the side menu to `form`. + public static void menu(Form form) { + Toolbar bar = form.getToolbar(); + UserDto user = Session.user(); + if (user != null) { + Label who = new Label(user.displayName() == null ? user.username() : user.displayName(), + "BnMenuHeader"); + bar.addComponentToSideMenu(who); + } + bar.addMaterialCommandToSideMenu(Lang.tr("Accounts"), FontImage.MATERIAL_ACCOUNT_BALANCE, + evt -> home()); + bar.addMaterialCommandToSideMenu(Lang.tr("Move money"), FontImage.MATERIAL_SWAP_HORIZ, + evt -> TransferForm.show(null, false, Nav::home)); + bar.addMaterialCommandToSideMenu(Lang.tr("Payees"), FontImage.MATERIAL_PEOPLE, + evt -> PayeesForm.show(null)); + bar.addMaterialCommandToSideMenu(Lang.tr("Cards"), FontImage.MATERIAL_CREDIT_CARD, + evt -> CardsForm.show(null)); + bar.addMaterialCommandToSideMenu(Lang.tr("Exchange"), FontImage.MATERIAL_SHOW_CHART, + evt -> ExchangeForm.show(null)); + bar.addMaterialCommandToSideMenu(Lang.tr("Send crypto"), FontImage.MATERIAL_CALL_MADE, + evt -> WithdrawForm.show(null, Nav::home)); + bar.addMaterialCommandToSideMenu(Lang.tr("Receive crypto"), FontImage.MATERIAL_CALL_RECEIVED, + evt -> DepositForm.show(Nav::home)); + bar.addMaterialCommandToSideMenu(Lang.tr("Security Center"), FontImage.MATERIAL_SECURITY, + evt -> SecurityCenterForm.show(Nav::home)); + bar.addMaterialCommandToSideMenu(Lang.tr("Settings"), FontImage.MATERIAL_SETTINGS, + evt -> SettingsForm.show(null)); + if (staff(user)) { + bar.addMaterialCommandToSideMenu(Lang.tr(user.admin() ? "Administration" : "Audit"), + FontImage.MATERIAL_ADMIN_PANEL_SETTINGS, evt -> ConsoleForm.show()); + } + bar.addMaterialCommandToSideMenu(Lang.tr("Sign out"), FontImage.MATERIAL_LOGOUT, + evt -> signOut()); + } + + /// Whether `user` works for the bank: an administrator or an auditor. + public static boolean staff(UserDto user) { + return user != null && (user.admin() || user.auditor()); + } + + /// The dashboard; for staff with no customer accounts, the console. + public static void home() { + UserDto user = Session.user(); + if (user != null && !user.customer() && staff(user)) { + ConsoleForm.show(); + } else { + HomeForm.show(); + } + } + + public static void signOut() { + Session.signOut(signedOut); + } + + /// A form of the signed-in app. There is one way out of a screen, never + /// two: a screen reached from the menu (`back` null) is a top-level + /// screen and has the menu button; a screen pushed from another one has + /// a back arrow to `back`, and no menu. A back arrow beside a menu + /// button leaves the user guessing which of them is "up". + public static Form form(String title, Runnable back) { + Form form = Ui.form(title, back); + if (back == null) { + menu(form); + } + return form; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/OnboardingForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/OnboardingForm.java new file mode 100644 index 00000000000..bb70babb24b --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/OnboardingForm.java @@ -0,0 +1,431 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Command; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.bastion.Prefs; +import com.codenameone.examples.bastion.api.RecoveryCodesDto; +import com.codenameone.examples.bastion.api.RegisterDto; +import com.codenameone.examples.bastion.api.TotpCodeDto; +import com.codenameone.examples.bastion.api.TotpEnrollmentDto; +import com.codenameone.examples.bastion.api.TotpStartDto; +import com.codenameone.examples.bastion.api.UserDto; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.security.Keys; +import com.codenameone.examples.bastion.security.Lock; +import java.util.List; + +/// A new customer, from nothing to a phone that signs their payments. +/// +/// The steps, each a screen, each but the first skippable and offered again +/// from Settings: +/// +/// 1. **Register**: name, e-mail and a password the rules below accept -- +/// the same rule as the server's (`Passwords.MINIMUM`), checked as it is +/// typed so the server's refusal is never the first the user hears of it. +/// 2. **Sign in**, at once, keeping the server session for step 4. +/// 3. **An authenticator app**: the TOTP secret as a QR code and as text, +/// confirmed by a first code, then the recovery codes -- shown once, to +/// write down (never copied: see `Protect`). +/// 4. **A passkey**, through the platform's passkey sheet. +/// 5. **Biometric unlock**, if the device has one: a choice, not a default. +/// 6. **This device's signing key**, registered while the sign-in is fresh +/// enough for the server to accept it. +/// +/// Every screen with a secret on it blocks screenshots. +public final class OnboardingForm { + /// The server's rule (`Passwords` in the backend). + static final int MIN_PASSWORD = 12; + static final int MAX_PASSWORD = 200; + + private OnboardingForm() { + } + + /// The registration screen; `signedIn` runs at the end of onboarding. + public static void show(final Runnable back, final Runnable signedIn) { + Form form = Ui.form("Open an account", back); + form.add(Ui.title("Bank with Bastion")); + form.add(Ui.muted(Lang.tr("Takes two minutes. You will set up a second factor and this " + + "phone's signing key as you go."))); + final TextField name = Ui.field("Your name", TextArea.ANY); + final TextField email = Ui.field("E-mail", TextArea.EMAILADDR); + final TextField password = Ui.field("Password", TextArea.PASSWORD); + final TextField repeat = Ui.field("Password again", TextArea.PASSWORD); + final Label ruleLength = new Label("", "BnRule"); + final Label ruleMatch = new Label("", "BnRule"); + final Label ruleEmail = new Label("", "BnRule"); + final SpanLabel error = Ui.error(); + final Button go = Ui.primary("Create account"); + final Runnable check = () -> { + String[] problems = passwordProblems(password.getText(), repeat.getText(), email.getText()); + rule(ruleLength, Lang.tr("At least {0} characters", String.valueOf(MIN_PASSWORD)), + problems[0] == null); + rule(ruleMatch, Lang.tr("Both passwords match"), problems[1] == null); + rule(ruleEmail, Lang.tr("Not your e-mail address"), problems[2] == null); + go.setEnabled(problems[0] == null && problems[1] == null && problems[2] == null + && name.getText().trim().length() > 0 && email.getText().indexOf('@') > 0); + }; + password.addDataChangedListener((type, index) -> check.run()); + repeat.addDataChangedListener((type, index) -> check.run()); + email.addDataChangedListener((type, index) -> check.run()); + name.addDataChangedListener((type, index) -> check.run()); + check.run(); + form.addAll(name, email, password, repeat, ruleLength, ruleMatch, ruleEmail, error, go); + SpanLabel tip = new SpanLabel(Lang.tr("A few unrelated words make a password that is long " + + "and easy to remember.")); + tip.setTextUIID("BnMuted"); + form.add(tip); + go.addActionListener(evt -> { + go.setEnabled(false); + Ui.showError(error, null); + final String address = Session.accountName(email.getText()); + final String secret = password.getText(); + Api.account().register(new RegisterDto(address, secret, name.getText().trim()), + Net.to((UserDto user) -> { + // Kept for one passkey ceremony in step 4. + Session.keepSessionForPasskey(); + Session.signIn(address, secret, pending -> { + // A brand-new account has no second factor yet. + Session.endPasskeySession(); + Ui.showError(error, Lang.tr("Sign in to continue.")); + }, () -> { + password.setText(""); + repeat.setText(""); + totpStep(signedIn); + }, (status, message) -> { + go.setEnabled(true); + Ui.showError(error, Lang.tr(message)); + }); + }, (status, message) -> { + go.setEnabled(true); + Ui.showError(error, Lang.tr(message)); + })); + }); + Ui.show(form, true); + } + + /// What is wrong with a password, one slot per rule (null when the rule + /// holds): length, the repeat, and being the e-mail address itself. + static String[] passwordProblems(String password, String repeat, String email) { + String[] out = new String[3]; + int length = password == null ? 0 : password.length(); + if (length < MIN_PASSWORD || length > MAX_PASSWORD) { + out[0] = "length"; + } + if (length == 0 || !password.equals(repeat)) { + out[1] = "repeat"; + } + String account = Session.accountName(email); + if (length > 0 && account != null && account.length() > 0) { + String folded = Session.accountName(password); + int at = account.indexOf('@'); + String local = at > 0 ? account.substring(0, at) : account; + if (folded.equals(account) || (local.length() >= 4 && folded.indexOf(local) >= 0)) { + out[2] = "email"; + } + } + return out; + } + + private static void rule(Label label, String text, boolean met) { + label.setText((met ? "+ " : "- ") + text); + label.setUIID(met ? "BnRuleMet" : "BnRule"); + } + + // ------------------------------------------------------- authenticator + + private static void totpStep(final Runnable signedIn) { + totp(() -> passkeyStep(signedIn), () -> passkeyStep(signedIn)); + } + + /// Turns the authenticator app on from Settings. + public static void totp(final Runnable done) { + totp(done, done); + } + + private static void totp(final Runnable done, final Runnable skipped) { + // Adding an app is a step-up: after a sign-in made with the password + // alone the server wants the password again, so a stolen token cannot + // put somebody else's app on the account (SecondFactor on the server). + // Asked once here and sent with both calls, then dropped. + String password = null; + // Matches SecondFactor on the server: a passkey counts only when the + // authenticator verified the user (`hwk` with `user`). + if (!Session.signedInWith("mfa") && !Session.signedInWith("otp") + && !(Session.signedInWith("hwk") && Session.signedInWith("user"))) { + TextField field = new TextField("", Lang.tr("Password"), 20, TextArea.PASSWORD); + field.setUIID("BnField"); + Command ok = new Command(Lang.tr("Continue")); + if (Dialog.show(Lang.tr("Confirm it is you"), + // Enclosed, or the dialog stretches the one-line field to its + // whole body. + com.codename1.ui.layouts.BoxLayout.encloseY(field), ok, + new Command(Lang.tr("Cancel"))) != ok) { + skipped.run(); + return; + } + password = field.getText(); + } + final String confirmedPassword = password; + final Form form = Ui.form("Authenticator app", null); + form.add(Ui.title("Add a second factor")); + form.add(Ui.muted(Lang.tr("Scan this with an authenticator app. From now on a sign-in with " + + "your password also asks for the code it shows."))); + final Container content = new Container(BoxLayout.y()); + form.add(content); + Button later = Ui.secondary("Not now"); + later.addActionListener(evt -> skipped.run()); + form.add(later); + Ui.show(form, true); + Ui.loading(content); + Api.account().startTotp(new TotpStartDto(confirmedPassword), + Net.to((TotpEnrollmentDto enrollment) -> + showSecret(content, enrollment, confirmedPassword, done), + (status, message) -> Ui.failed(content, message, () -> totp(done, skipped)))); + } + + private static void showSecret(final Container content, final TotpEnrollmentDto enrollment, + final String password, final Runnable done) { + content.removeAll(); + byte[] uri = utf8(enrollment.otpauthUri()); + // A URI too long for the encoder (a very long e-mail address) still + // has the key below; the code is a convenience, the key the record. + if (uri.length <= QrCode.MAX_BYTES) { + Container qr = new Container(new FlowLayout(Container.CENTER)); + qr.add(new QrView(QrCode.encode(uri))); + content.add(qr); + } + content.add(Ui.section("Or enter this key")); + content.add(Ui.keyGroups(group(enrollment.secret()))); + Button copy = Ui.secondary("Copy key"); + copy.addActionListener(evt -> Ui.copy(enrollment.secret(), true)); + Button open = Ui.secondary("Open authenticator"); + open.addActionListener(evt -> CN.execute(enrollment.otpauthUri())); + content.add(Ui.across(copy, open)); + final TextField code = Ui.field("Six-digit code", TextArea.NUMERIC); + final SpanLabel error = Ui.error(); + final Button confirm = Ui.primary("Turn on"); + content.addAll(code, error, confirm); + confirm.addActionListener(evt -> { + confirm.setEnabled(false); + Api.account().confirmTotp(new TotpCodeDto(code.getText().trim(), password), + Net.to((RecoveryCodesDto codes) -> Session.refreshUser( + () -> recoveryCodes(codes.codes(), done), + (status, message) -> recoveryCodes(codes.codes(), done)), + (status, message) -> { + confirm.setEnabled(true); + code.setText(""); + Ui.showError(error, Lang.tr(message)); + })); + }); + content.revalidate(); + } + + /// Makes a fresh set of recovery codes, the old ones void; asks for a + /// current code first, as the server does. + public static void newRecoveryCodes(final Runnable done) { + TextField code = Ui.field("Six-digit code", TextArea.NUMERIC); + Command ok = new Command(Lang.tr("Continue")); + if (Dialog.show(Lang.tr("New recovery codes"), code, ok, new Command(Lang.tr("Cancel"))) != ok) { + return; + } + Api.account().newRecoveryCodes(new TotpCodeDto(code.getText().trim(), null), + Net.to((RecoveryCodesDto codes) -> Session.refreshUser( + () -> recoveryCodes(codes.codes(), done), + (status, message) -> recoveryCodes(codes.codes(), done)), + (status, message) -> Dialog.show(Lang.tr("Not changed"), Lang.tr(message), + Lang.tr("OK"), null))); + } + + /// The recovery codes, shown this once: the server keeps only their + /// hashes, so this is the only time anyone can read them. + private static void recoveryCodes(final List codes, final Runnable done) { + Form form = Ui.form("Recovery codes", null); + form.add(Ui.title("Save your recovery codes")); + form.add(Ui.muted(Lang.tr("Each signs you in once if you lose your phone. Keep them " + + "somewhere safe and offline. They will not be shown again."))); + Container grid = new Container(BoxLayout.y(), "BnPanel"); + for (String one : codes) { + grid.add(new Label(one, "BnSecret")); + } + form.add(grid); + // Copied as a sensitive clip that clears itself (Protect#copy): long + // enough to paste into a password manager, kept out of the keyboard's + // history and the copy preview where the platform allows it. + Button copy = Ui.secondary("Copy codes"); + copy.addActionListener(evt -> { + StringBuilder all = new StringBuilder(); + for (String one : codes) { + all.append(one).append('\n'); + } + Ui.copy(all.toString(), true); + }); + form.add(copy); + Button saved = Ui.primary("I have saved them"); + saved.addActionListener(evt -> done.run()); + form.add(saved); + Ui.show(form, true); + } + + // -------------------------------------------------------------- passkey + + private static void passkeyStep(final Runnable signedIn) { + if (!Session.canAddPasskey()) { + Session.endPasskeySession(); + biometricStep(signedIn); + return; + } + Form form = Ui.form("Passkey", null); + form.add(Ui.title("Sign in with a passkey")); + form.add(Ui.muted(Lang.tr("A passkey is kept by your phone and unlocked by your face, " + + "fingerprint or screen lock. It cannot be phished, and replaces both the " + + "password and the code."))); + final SpanLabel error = Ui.error(); + final Button create = Ui.primary("Create a passkey"); + create.addActionListener(evt -> { + create.setEnabled(false); + Session.addPasskey(Lang.tr("Bastion on {0}", CN.getPlatformName()), + () -> biometricStep(signedIn), (status, message) -> { + // The kept session is gone either way; Settings can't + // retry without a new sign-in, so move on. + Ui.showError(error, Lang.tr(message)); + create.setEnabled(false); + }); + }); + Button next = Ui.secondary("Continue"); + next.addActionListener(evt -> { + Session.endPasskeySession(); + biometricStep(signedIn); + }); + form.addAll(error, create, next); + Ui.show(form, false); + } + + // ------------------------------------------------------------ biometric + + private static void biometricStep(final Runnable signedIn) { + if (!Lock.biometricsAvailable()) { + pinStep(signedIn); + return; + } + Form form = Ui.form("Unlock", null); + form.add(Ui.title("Unlock with your face or fingerprint?")); + form.add(Ui.muted(Lang.tr("Bastion locks itself when you leave it. With this on, the " + + "phone keeps the key to your session and payments where only your biometric " + + "releases it. The device passcode is never accepted."))); + final SpanLabel error = Ui.error(); + Button yes = Ui.primary("Use biometrics"); + yes.addActionListener(evt -> UnlockSetup.biometric(() -> pinStep(signedIn), error)); + Button no = Ui.secondary("Not now"); + no.addActionListener(evt -> { + Keys.disableBiometric(); + pinStep(signedIn); + }); + form.addAll(error, yes, no); + Ui.show(form, false); + } + + // ------------------------------------------------------------------ PIN + + private static void pinStep(final Runnable signedIn) { + if (Keys.pinEnabled()) { + deviceStep(signedIn); + return; + } + Form form = Ui.form("Unlock PIN", null); + form.add(Ui.title("Choose an unlock PIN")); + form.add(Ui.muted(Lang.tr("For when a biometric is not available or you would rather " + + "not use one: {0} to {1} digits, not your card PIN. {2} wrong tries in a row " + + "erase the keys on this device.", String.valueOf(Keys.MIN_PIN), + String.valueOf(Keys.MAX_PIN), String.valueOf(Keys.PIN_ATTEMPTS)))); + final SpanLabel error = Ui.error(); + final UnlockSetup.PinFields fields = new UnlockSetup.PinFields(); + Button set = Ui.primary("Set PIN"); + set.addActionListener(evt -> fields.save(() -> deviceStep(signedIn), error)); + Button no = Ui.secondary("Not now"); + no.addActionListener(evt -> deviceStep(signedIn)); + form.addAll(error, fields.pin, fields.again, set, no); + Ui.show(form, true); + } + + // --------------------------------------------------------------- device + + private static void deviceStep(final Runnable signedIn) { + final Runnable finish = () -> { + UserDto user = Session.user(); + Prefs.setOnboarded(user == null ? null : user.username()); + signedIn.run(); + }; + if (DeviceSetup.registered()) { + finish.run(); + return; + } + Form form = Ui.form("This device", null); + form.add(Ui.title("Let this phone sign your payments")); + form.add(Ui.muted(Lang.tr("Bastion makes a key on this phone and gives the bank only its " + + "public half. Payments to other people and crypto withdrawals must carry its " + + "signature, so a stolen password alone cannot move your money."))); + final SpanLabel error = Ui.error(); + final Button register = Ui.primary("Register this device"); + register.addActionListener(evt -> { + register.setEnabled(false); + DeviceSetup.register(finish, (status, message) -> { + register.setEnabled(true); + Ui.showError(error, Lang.tr(message)); + }); + }); + Button later = Ui.secondary("Later"); + later.addActionListener(evt -> finish.run()); + form.addAll(error, register, later); + Ui.show(form, false); + } + + /// A base32 secret in groups of four, as authenticator apps show it. + static String group(String secret) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < secret.length(); iter++) { + if (iter > 0 && iter % 4 == 0) { + out.append(' '); + } + out.append(secret.charAt(iter)); + } + return out.toString(); + } + + private static byte[] utf8(String text) { + try { + return text.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException impossible) { + return text.getBytes(); + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/PayeesForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/PayeesForm.java new file mode 100644 index 00000000000..b0c4865d0fd --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/PayeesForm.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.Command; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.bastion.api.NewPayeeDto; +import com.codenameone.examples.bastion.api.PayeeDto; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.security.RequestSigner; +import java.util.List; + +/// The people the user pays. +/// +/// Adding one is signed by the device, because a payee is where stolen money +/// goes: an attacker with a session alone cannot add their own account. A +/// new payee then **cools off** -- for a while it may receive only a small +/// amount -- so even a signed add made under duress buys the owner time. The +/// list shows each payee's cooling-off end; renaming and removing are not +/// signed, since neither can send money anywhere new. +public final class PayeesForm { + private static final String TARGET = "/api/payees"; + + private PayeesForm() { + } + + public static void show(final Runnable back) { + Form form = Nav.form("Payees", back); + final Container content = new Container(BoxLayout.y()); + form.add(content); + Ui.show(form, true); + load(content); + } + + private static void load(final Container content) { + Ui.loading(content); + Api.banking().payees(Net.to((List payees) -> fill(content, payees), + (status, message) -> Ui.failed(content, message, () -> load(content)))); + } + + private static void fill(final Container content, List payees) { + content.removeAll(); + Button add = Ui.primary("Add a payee"); + add.addActionListener(evt -> add(content)); + content.add(add); + if (payees.isEmpty()) { + content.add(Ui.empty("No payees yet. Add one by the e-mail address they bank with.")); + } + long now = System.currentTimeMillis(); + for (final PayeeDto payee : payees) { + boolean cooling = payee.coolingOffUntil() > now; + String detail = payee.email() + (cooling + ? "\n" + Lang.tr("New payee: limited until {0}", Ui.when(payee.coolingOffUntil())) + : ""); + content.add(Ui.row(payee.nickname(), detail, Lang.tr(cooling ? "Cooling off" : "Ready"), + cooling ? "BnBadgeWarn" : "BnBadgeGood", () -> manage(content, payee))); + } + content.revalidate(); + } + + private static void add(final Container content) { + final TextField email = Ui.field("Their e-mail", TextField.EMAILADDR); + final TextField nickname = Ui.field("Name to show", TextField.ANY); + Container body = new Container(BoxLayout.y()); + body.addAll(email, nickname); + Command ok = new Command(Lang.tr("Add")); + if (Dialog.show(Lang.tr("Add a payee"), body, ok, new Command(Lang.tr("Cancel"))) != ok) { + return; + } + final NewPayeeDto payee = new NewPayeeDto(Session.accountName(email.getText()), + nickname.getText().trim()); + DeviceSetup.withDevice(failed -> Api.banking().addPayee( + RequestSigner.sign("POST", TARGET, null, payee), payee, + Net.to((PayeeDto added) -> { + Ui.toast("Payee added. It is limited while it cools off."); + load(content); + }, failed)), + (status, message) -> Dialog.show(Lang.tr("Not added"), Lang.tr(message), + Lang.tr("OK"), null)); + } + + private static void manage(final Container content, final PayeeDto payee) { + Command rename = new Command(Lang.tr("Rename")); + Command remove = new Command(Lang.tr("Remove")); + Command picked = Dialog.show(payee.nickname(), payee.email(), rename, remove, + new Command(Lang.tr("Cancel"))); + Net.Failed failed = (status, message) -> Dialog.show(Lang.tr("Not changed"), + Lang.tr(message), Lang.tr("OK"), null); + if (picked == rename) { + TextField name = Ui.field("Name to show", TextField.ANY); + name.setText(payee.nickname()); + Command ok = new Command(Lang.tr("Save")); + if (Dialog.show(Lang.tr("Rename"), name, ok, new Command(Lang.tr("Cancel"))) == ok) { + final NewPayeeDto renamed = new NewPayeeDto(payee.email(), name.getText().trim()); + DeviceSetup.withDevice(retry -> Api.banking().renamePayee(payee.id(), + RequestSigner.sign("PUT", TARGET + "/" + payee.id(), null, renamed), renamed, + Net.to((PayeeDto done) -> load(content), retry)), failed); + } + } else if (picked == remove && Ui.confirm("Remove payee", + Lang.tr("Remove {0}? Adding them again starts a new cooling-off.", payee.nickname()), + "Remove")) { + DeviceSetup.withDevice(retry -> Api.banking().removePayee(payee.id(), + RequestSigner.sign("DELETE", TARGET + "/" + payee.id(), null, null), + Net.to((PayeeDto done) -> load(content), retry)), failed); + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/PdfPages.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/PdfPages.java new file mode 100644 index 00000000000..a2a739d4a22 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/PdfPages.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.io.gzip.DeflaterOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// A PDF of whole-page pictures, one per page: what [Printout] hands to the +/// print dialog. Each page is a sheet of the given size in points with one +/// RGB image on it, deflated, filling the sheet. Nothing else of PDF is +/// written -- no fonts, no text -- so a reader of any version opens it. +final class PdfPages { + private final int widthPoints; + private final int heightPoints; + private final List images = new ArrayList(); + private final List sizes = new ArrayList(); + + PdfPages(int widthPoints, int heightPoints) { + this.widthPoints = widthPoints; + this.heightPoints = heightPoints; + } + + /// Adds a page showing `argb`, `width` by `height` pixels, row by row + /// from the top; alpha is ignored. + void add(int[] argb, int width, int height) throws IOException { + ByteArrayOutputStream packed = new ByteArrayOutputStream(); + DeflaterOutputStream deflate = new DeflaterOutputStream(packed); + byte[] row = new byte[width * 3]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int pixel = argb[y * width + x]; + row[x * 3] = (byte) (pixel >> 16); + row[x * 3 + 1] = (byte) (pixel >> 8); + row[x * 3 + 2] = (byte) pixel; + } + deflate.write(row); + } + deflate.close(); + images.add(packed.toByteArray()); + sizes.add(new int[] {width, height}); + } + + int pageCount() { + return images.size(); + } + + /// The document. Objects: 1 the catalog, 2 the page tree, then three per + /// page -- the page, its drawing, its image. + byte[] toBytes() throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + List offsets = new ArrayList(); + ascii(out, "%PDF-1.4\n"); + offsets.add(Integer.valueOf(out.size())); + ascii(out, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + StringBuilder kids = new StringBuilder(); + for (int iter = 0; iter < images.size(); iter++) { + kids.append(3 + iter * 3).append(" 0 R "); + } + offsets.add(Integer.valueOf(out.size())); + ascii(out, "2 0 obj\n<< /Type /Pages /Kids [" + kids + "] /Count " + images.size() + + " >>\nendobj\n"); + for (int iter = 0; iter < images.size(); iter++) { + int page = 3 + iter * 3; + int[] size = sizes.get(iter); + byte[] image = images.get(iter); + String draw = "q " + widthPoints + " 0 0 " + heightPoints + " 0 0 cm /P Do Q\n"; + offsets.add(Integer.valueOf(out.size())); + ascii(out, page + " 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + widthPoints + + " " + heightPoints + "] /Resources << /XObject << /P " + (page + 2) + + " 0 R >> >> /Contents " + (page + 1) + " 0 R >>\nendobj\n"); + offsets.add(Integer.valueOf(out.size())); + ascii(out, (page + 1) + " 0 obj\n<< /Length " + draw.length() + " >>\nstream\n" + draw + + "endstream\nendobj\n"); + offsets.add(Integer.valueOf(out.size())); + ascii(out, (page + 2) + " 0 obj\n<< /Type /XObject /Subtype /Image /Width " + size[0] + + " /Height " + size[1] + " /ColorSpace /DeviceRGB /BitsPerComponent 8" + + " /Filter /FlateDecode /Length " + image.length + " >>\nstream\n"); + out.write(image); + ascii(out, "\nendstream\nendobj\n"); + } + int xref = out.size(); + ascii(out, "xref\n0 " + (offsets.size() + 1) + "\n0000000000 65535 f \n"); + for (Integer at : offsets) { + ascii(out, pad10(at.intValue()) + " 00000 n \n"); + } + ascii(out, "trailer\n<< /Size " + (offsets.size() + 1) + " /Root 1 0 R >>\nstartxref\n" + + xref + "\n%%EOF\n"); + return out.toByteArray(); + } + + private static String pad10(int value) { + String text = String.valueOf(value); + while (text.length() < 10) { + text = "0" + text; + } + return text; + } + + /// Everything but the image data is ASCII, written a byte per character. + private static void ascii(ByteArrayOutputStream out, String text) { + for (int iter = 0; iter < text.length(); iter++) { + out.write(text.charAt(iter)); + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Printout.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Printout.java new file mode 100644 index 00000000000..9648d516ea0 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Printout.java @@ -0,0 +1,497 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.components.ToastBar; +import com.codename1.io.FileSystemStorage; +import com.codename1.printing.PrintResult; +import com.codename1.printing.Printer; +import com.codename1.ui.Button; +import com.codename1.ui.Font; +import com.codename1.ui.FontImage; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.TimeZone; + +/// A report as it goes on paper: the bank's name, what the report is, the +/// account and period it covers and when it was made; then its tables; then +/// its totals. +/// +/// The report is laid out as lines of monospaced text ([#lines]) -- columns +/// line up without measuring a font, and the layout is a plain list of +/// strings a test can read -- cut into A4 pages ([#pages]), each drawn onto a +/// white image ([#render]); the pages go into one PDF ([PdfPages]), which +/// [Printer#printPDF] hands to the platform's print dialog. Every text is +/// given already translated: the screen that builds the printout has the +/// words the reader sees. +public final class Printout { + /// Characters across the page. + public static final int COLUMNS = 96; + /// The name at the top of every page. + public static final String BANK = "Bastion"; + + private final String title; + private final long generated; + /// The header's word for [#generated]: translated when the page is + /// printed, so that laying it out needs no theme. + private String generatedLabel = "Generated"; + /// "Page {0} of {1}" and "{0} (continued)", likewise translated at print time. + private String pageLabel = "Page {0} of {1}"; + private String continuedLabel = "{0} (continued)"; + private final List facts = new ArrayList(); + private final List
sections = new ArrayList
(); + private final List totals = new ArrayList(); + + /// One table of the report. + public static final class Section { + String caption; + final String[] headers; + final int[] weights; + final boolean[] numeric; + final List rows = new ArrayList(); + /// Per row: 0 a line, 1 a total, 2 a note across the table. + final List kinds = new ArrayList(); + + Section(String caption, String[] headers, int[] weights, boolean[] numeric) { + this.caption = caption; + this.headers = headers; + this.weights = weights; + this.numeric = numeric; + } + + /// Names the table on paper. + public Section caption(String text) { + caption = text; + return this; + } + + /// Adds a line of the table. + public Section row(String... cells) { + rows.add(cells); + kinds.add(Integer.valueOf(0)); + return this; + } + + /// Adds a total line, set apart from the lines above it. + public Section total(String... cells) { + rows.add(cells); + kinds.add(Integer.valueOf(1)); + return this; + } + + /// Adds a line of text across the whole table. + public Section note(String text) { + rows.add(new String[] {text}); + kinds.add(Integer.valueOf(2)); + return this; + } + + public int size() { + return rows.size(); + } + } + + /// @param title what the report is, translated + /// @param generated when it was made, in milliseconds + public Printout(String title, long generated) { + this.title = title; + this.generated = generated; + } + + /// A line of the header: the account, the period. + public Printout fact(String label, String value) { + facts.add(new String[] {label, value == null ? "" : value}); + return this; + } + + /// A new table, `caption` (may be null) above it. + public Section section(String caption, String[] headers, int[] weights, boolean[] numeric) { + Section s = new Section(caption, headers, weights, numeric); + sections.add(s); + return s; + } + + /// Adds a table the screen already filled. + public Printout add(Section section) { + sections.add(section); + return this; + } + + /// A line of the totals at the end. + public Printout total(String label, String value) { + totals.add(new String[] {label, value == null ? "" : value}); + return this; + } + + public String title() { + return title; + } + + /// The report, as lines of at most [#COLUMNS] characters, before it is + /// cut into pages ([#pages]). + public List lines() { + List out = new ArrayList(); + for (Line line : body()) { + out.add(line.text); + } + return out; + } + + /// One line of the report, with what pagination needs to know about it. + private static final class Line { + final String text; + /// Bold on paper: the bank, the title, the totals at the end. + final boolean bold; + /// Opens a table (the space, caption, column names and rule above + /// its first row): never left at the foot of a page on its own. + final boolean opens; + /// For a row of a table, the lines that head the table again at the + /// top of a page the table runs on to; null for anything else. + final String[] heading; + + Line(String text, boolean bold, boolean opens, String[] heading) { + this.text = text; + this.bold = bold; + this.opens = opens; + this.heading = heading; + } + } + + private List body() { + List out = new ArrayList(); + out.add(new Line(BANK, true, false, null)); + out.add(new Line(fit(title, COLUMNS), true, false, null)); + out.add(new Line(repeat('=', COLUMNS), false, false, null)); + int labelWidth = generatedLabel.length(); + for (String[] f : facts) { + labelWidth = Math.max(labelWidth, f[0].length()); + } + labelWidth = Math.min(labelWidth, COLUMNS / 3); + for (String[] f : facts) { + out.add(new Line(fit(pad(fit(f[0], labelWidth) + ":", labelWidth + 2) + f[1], COLUMNS), + false, false, null)); + } + out.add(new Line(pad(generatedLabel + ":", labelWidth + 2) + stamp(generated), false, false, null)); + for (Section s : sections) { + int[] widths = widths(s.weights, COLUMNS); + String header = cells(s.headers, widths, s.numeric); + String rule = repeat('-', COLUMNS); + boolean captioned = s.caption != null && s.caption.length() > 0; + String[] heading = captioned + ? new String[] {fit(format(continuedLabel, "{0}", s.caption), COLUMNS), header, rule} + : new String[] {header, rule}; + out.add(new Line("", false, true, null)); + if (captioned) { + out.add(new Line(fit(s.caption, COLUMNS), false, true, null)); + } + out.add(new Line(header, false, true, null)); + out.add(new Line(rule, false, true, null)); + boolean totalsBegun = false; + for (int iter = 0; iter < s.rows.size(); iter++) { + int kind = s.kinds.get(iter).intValue(); + if (kind == 2) { + out.add(new Line(fit(s.rows.get(iter)[0], COLUMNS), false, false, heading)); + continue; + } + if (kind == 1 && !totalsBegun) { + out.add(new Line(rule, false, false, heading)); + totalsBegun = true; + } + out.add(new Line(cells(s.rows.get(iter), widths, s.numeric), false, false, heading)); + } + } + if (!totals.isEmpty()) { + out.add(new Line("", false, true, null)); + out.add(new Line(repeat('=', COLUMNS), false, true, null)); + for (String[] t : totals) { + int room = COLUMNS - t[1].length() - 1; + // The totals stay together: every one but the last opens. + boolean last = t == totals.get(totals.size() - 1); + out.add(new Line(pad(fit(t[0], Math.max(1, room)), Math.max(1, room) + 1) + t[1], + true, !last, null)); + } + } + return out; + } + + /// A sheet of paper: its lines, top to bottom, the last one its page + /// number. + public static final class Page { + final List lines = new ArrayList(); + final List bold = new ArrayList(); + + public List lines() { + return lines; + } + + boolean isBold(int index) { + return bold.get(index).booleanValue(); + } + + void add(String text, boolean strong) { + lines.add(text); + bold.add(Boolean.valueOf(strong)); + } + } + + /// The report cut into pages of `linesPerPage` lines. The first page has + /// the full header; every page after it names the bank and the report, + /// and a table that runs on to it repeats its column names there. A table + /// never starts at the foot of a page with none of its rows under it, the + /// totals at the end stay together, and the last line of every page is + /// "Page X of Y". + public List pages(int linesPerPage) { + // Two lines at the foot: a space, then the page number. + int room = Math.max(8, linesPerPage) - 2; + List all = body(); + List pages = new ArrayList(); + Page page = new Page(); + int bodyLines = 0; + for (int iter = 0; iter < all.size(); iter++) { + Line line = all.get(iter); + // What has to fit together: a run of opening lines and the line + // after it. + int together = 1; + if (line.opens) { + int at = iter; + while (at < all.size() && all.get(at).opens) { + at++; + } + together = Math.min(at, all.size() - 1) - iter + 1; + } + if (page.lines.size() + together > room && bodyLines > 0) { + pages.add(page); + page = new Page(); + bodyLines = 0; + page.add(fit(BANK + " - " + title, COLUMNS), true); + page.add(repeat('=', COLUMNS), false); + if (!line.opens && line.heading != null) { + for (String h : line.heading) { + page.add(h, false); + } + } + } + if (bodyLines == 0 && pages.size() > 0 && line.text.length() == 0) { + // A page does not start with a space. + continue; + } + page.add(line.text, line.bold); + bodyLines++; + } + pages.add(page); + for (int iter = 0; iter < pages.size(); iter++) { + Page p = pages.get(iter); + while (p.lines.size() < room + 1) { + p.add("", false); + } + String number = fit(format(format(pageLabel, "{0}", String.valueOf(iter + 1)), "{1}", + String.valueOf(pages.size())), COLUMNS); + p.add(repeat(' ', COLUMNS - number.length()) + number, false); + } + return pages; + } + + /// `pattern` with `mark` replaced by `value`. + private static String format(String pattern, String mark, String value) { + int at = pattern.indexOf(mark); + return at < 0 ? pattern + : pattern.substring(0, at) + value + pattern.substring(at + mark.length()); + } + + /// Paper is A4 upright: 210 by 297 millimetres. + static final int PAGE_WIDTH_POINTS = 595; + static final int PAGE_HEIGHT_POINTS = 842; + + /// The pages drawn in black on white, each the shape of a sheet of A4. + public List render() { + Font mono = Font.createSystemFont(Font.FACE_MONOSPACE, Font.STYLE_PLAIN, Font.SIZE_SMALL); + Font bold = Font.createSystemFont(Font.FACE_MONOSPACE, Font.STYLE_BOLD, Font.SIZE_SMALL); + int cell = Math.max(1, mono.charWidth('M')); + int line = mono.getHeight() + 2; + int margin = cell * 4; + int width = cell * COLUMNS + margin * 2; + int height = width * PAGE_HEIGHT_POINTS / PAGE_WIDTH_POINTS; + List out = new ArrayList(); + for (Page p : pages((height - margin * 2) / line)) { + Image image = Image.createImage(width, height, 0xffffffff); + Graphics g = image.getGraphics(); + g.setAntiAliased(true); + g.setAntiAliasedText(true); + g.setColor(0x000000); + for (int iter = 0; iter < p.lines.size(); iter++) { + String text = p.lines.get(iter); + g.setFont(p.isBold(iter) ? bold : mono); + // Monospaced by position, not by the font's own advance: a + // character the font lacks must not move the next column. + int y = margin + iter * line; + for (int at = 0; at < text.length(); at++) { + char c = text.charAt(at); + if (c != ' ') { + g.drawChar(c, margin + at * cell, y); + } + } + } + out.add(image); + } + return out; + } + + /// Makes the printout when it is asked for, so that it holds what the + /// screen shows by then. + public interface Source { + Printout get(); + } + + /// Whether this platform can print. Where it cannot, a screen shows no + /// print button rather than one that fails. + public static boolean supported() { + return Printer.isPrintingSupported(); + } + + /// A print button for what `source` makes, or null where this platform + /// cannot print. + public static Button button(final Source source) { + if (!supported()) { + return null; + } + Button print = new Button(Lang.tr("Print"), "BnSecondary"); + FontImage.setMaterialIcon(print, FontImage.MATERIAL_PRINT); + print.addActionListener(evt -> print(source.get())); + return print; + } + + /// Hands `printout` to the platform's print dialog, and says how it went. + public static void print(Printout printout) { + if (!Printer.isPrintingSupported()) { + ToastBar.showErrorMessage(Lang.tr("Printing is not available on this device.")); + return; + } + printout.generatedLabel = Lang.tr("Generated"); + // Translated with their placeholders left in; the page fills them. + printout.pageLabel = Lang.tr("Page {0} of {1}"); + printout.continuedLabel = Lang.tr("{0} (continued)"); + final FileSystemStorage fs = FileSystemStorage.getInstance(); + final String path = fs.getAppHomePath() + "bastion-report.pdf"; + try { + PdfPages pdf = new PdfPages(PAGE_WIDTH_POINTS, PAGE_HEIGHT_POINTS); + for (Image page : printout.render()) { + pdf.add(page.getRGB(), page.getWidth(), page.getHeight()); + } + OutputStream out = fs.openOutputStream(path); + try { + out.write(pdf.toBytes()); + } finally { + out.close(); + } + } catch (IOException err) { + ToastBar.showErrorMessage(Lang.tr("Could not print: {0}", String.valueOf(err.getMessage()))); + return; + } + // One document of many pages, so the print dialog paginates it and + // shows them all, rather than shrinking one tall image to a sheet. + Printer.printPDF(path, result -> { + fs.delete(path); + if (result.getStatus() == PrintResult.STATUS_FAILED) { + ToastBar.showErrorMessage(Lang.tr("Could not print: {0}", + String.valueOf(result.getError()))); + } else if (result.getStatus() == PrintResult.STATUS_COMPLETED) { + ToastBar.showInfoMessage(Lang.tr("Sent to the printer.")); + } + }); + } + + /// `millis` as `yyyy-MM-dd HH:mm UTC`: a printed report is read later and + /// elsewhere, so it names its time zone rather than assume the reader's. + static String stamp(long millis) { + Calendar c = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + c.setTime(new Date(millis)); + return c.get(Calendar.YEAR) + "-" + two(c.get(Calendar.MONTH) + 1) + "-" + + two(c.get(Calendar.DAY_OF_MONTH)) + " " + two(c.get(Calendar.HOUR_OF_DAY)) + + ":" + two(c.get(Calendar.MINUTE)) + " UTC"; + } + + private static String two(int value) { + return value < 10 ? "0" + value : String.valueOf(value); + } + + /// Each column's characters, shared out by weight, one space between. + static int[] widths(int[] weights, int columns) { + int total = 0; + for (int w : weights) { + total += w; + } + int room = columns - (weights.length - 1); + int[] out = new int[weights.length]; + int used = 0; + for (int iter = 0; iter < weights.length; iter++) { + out[iter] = iter == weights.length - 1 ? room - used + : Math.max(1, room * weights[iter] / Math.max(1, total)); + used += out[iter]; + } + return out; + } + + private static String cells(String[] cells, int[] widths, boolean[] numeric) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < widths.length; iter++) { + String text = iter < cells.length && cells[iter] != null ? cells[iter] : ""; + text = fit(text, widths[iter]); + if (iter > 0) { + out.append(' '); + } + if (numeric[iter]) { + out.append(repeat(' ', widths[iter] - text.length())).append(text); + } else { + out.append(text).append(repeat(' ', widths[iter] - text.length())); + } + } + // No trailing spaces: a line ends where its text does. + int end = out.length(); + while (end > 0 && out.charAt(end - 1) == ' ') { + end--; + } + return out.substring(0, end); + } + + /// `text` cut to `width`, ending in `..` when it was cut. + static String fit(String text, int width) { + if (text.length() <= width) { + return text; + } + return width <= 2 ? text.substring(0, width) : text.substring(0, width - 2) + ".."; + } + + private static String pad(String text, int width) { + return text.length() >= width ? text : text + repeat(' ', width - text.length()); + } + + private static String repeat(char c, int count) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < count; iter++) { + out.append(c); + } + return out.toString(); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/QrCode.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/QrCode.java new file mode 100644 index 00000000000..bed5c45e68c --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/QrCode.java @@ -0,0 +1,510 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +/// A QR code encoder, small enough to read: byte mode, error correction +/// level M, versions 1 to 10 (up to 213 bytes), mask chosen by the standard's +/// penalty score. Written for one job -- showing an `otpauth://` URI so an +/// authenticator app can scan the TOTP secret -- and written here because the +/// framework scans codes ([com.codename1.codescan]) but does not draw them. +/// +/// The steps are the standard's (ISO/IEC 18004), in order: +/// +/// 1. the data as bits: mode, length, bytes, terminator, padding; +/// 2. split into blocks, each given Reed-Solomon check bytes over GF(256); +/// 3. the blocks interleaved, and placed in a zigzag around the fixed +/// patterns (finders, timing, alignment, format and version areas); +/// 4. each of the eight masks tried, the one with the lowest penalty kept, +/// and the format bits that name it written in. +/// +/// `QrCodeTest` checks the matrices against another encoder's for every +/// version this supports. +public final class QrCode { + /// Per version 1..10 at level M: check bytes per block, then the blocks of + /// the short group and their data bytes, then those of the long group. + private static final int[][] BLOCKS = { + {10, 1, 16, 0, 0}, + {16, 1, 28, 0, 0}, + {26, 1, 44, 0, 0}, + {18, 2, 32, 0, 0}, + {24, 2, 43, 0, 0}, + {16, 4, 27, 0, 0}, + {18, 4, 31, 0, 0}, + {22, 2, 38, 2, 39}, + {22, 3, 36, 2, 37}, + {26, 4, 43, 1, 44}, + }; + + /// The centres of the alignment patterns, per version. + private static final int[][] ALIGN = { + {}, {6, 18}, {6, 22}, {6, 26}, {6, 30}, {6, 34}, + {6, 22, 38}, {6, 24, 42}, {6, 26, 46}, {6, 28, 50}, + }; + + /// The most bytes [#encode(byte[])] takes: version 10 at level M. + public static final int MAX_BYTES = 213; + + /// Level M's two bits in the format information. + private static final int LEVEL_M = 0; + + private final int size; + private final boolean[][] dark; + private final boolean[][] fixed; + + private QrCode(int version) { + size = version * 4 + 17; + dark = new boolean[size][size]; + fixed = new boolean[size][size]; + } + + /// The code for `data` at the smallest version that holds it. + public static QrCode encode(byte[] data) { + return encode(data, -1); + } + + /// The code for `data`, with mask `mask` (0..7), or the best mask when it + /// is -1. A fixed mask is for the tests. + public static QrCode encode(byte[] data, int mask) { + int version = 1; + while (version <= BLOCKS.length && capacity(version) < data.length) { + version++; + } + if (version > BLOCKS.length) { + throw new IllegalArgumentException("Too long for a QR code here: " + data.length); + } + return encode(data, version, mask); + } + + /// The code for `data` at exactly `version`, for the tests. + public static QrCode encode(byte[] data, int version, int mask) { + if (capacity(version) < data.length) { + throw new IllegalArgumentException("Does not fit version " + version); + } + QrCode code = new QrCode(version); + code.drawPatterns(version); + code.place(codewords(data, version)); + if (mask >= 0) { + code.applyMask(mask); + code.drawFormat(mask); + return code; + } + int best = 0; + int bestScore = Integer.MAX_VALUE; + for (int candidate = 0; candidate < 8; candidate++) { + code.applyMask(candidate); + code.drawFormat(candidate); + int score = code.penalty(); + if (score < bestScore) { + bestScore = score; + best = candidate; + } + // Masking is its own inverse: applied again, it takes itself off. + code.applyMask(candidate); + } + code.applyMask(best); + code.drawFormat(best); + return code; + } + + /// Modules per side, without the quiet zone. + public int size() { + return size; + } + + /// Whether the module in column `x`, row `y` is dark. + public boolean isDark(int x, int y) { + return dark[y][x]; + } + + /// The bytes version `version` holds in byte mode at level M. + static int capacity(int version) { + int[] b = BLOCKS[version - 1]; + int dataBytes = b[1] * b[2] + b[3] * b[4]; + // Four bits of mode, then 8 bits of length below version 10, 16 from it. + int header = 4 + (version < 10 ? 8 : 16); + return (dataBytes * 8 - header) / 8; + } + + // ------------------------------------------------------------- the bytes + + /// The final sequence of codewords: data blocks interleaved, then their + /// check bytes interleaved. + private static byte[] codewords(byte[] data, int version) { + int[] b = BLOCKS[version - 1]; + int dataBytes = b[1] * b[2] + b[3] * b[4]; + byte[] stream = dataStream(data, version, dataBytes); + int blocks = b[1] + b[3]; + byte[][] blockData = new byte[blocks][]; + byte[][] blockCheck = new byte[blocks][]; + byte[] generator = generator(b[0]); + int at = 0; + for (int iter = 0; iter < blocks; iter++) { + int length = iter < b[1] ? b[2] : b[4]; + blockData[iter] = new byte[length]; + System.arraycopy(stream, at, blockData[iter], 0, length); + at += length; + blockCheck[iter] = remainder(blockData[iter], generator); + } + byte[] out = new byte[dataBytes + blocks * b[0]]; + int pos = 0; + int longest = Math.max(b[2], b[4]); + for (int column = 0; column < longest; column++) { + for (int iter = 0; iter < blocks; iter++) { + if (column < blockData[iter].length) { + out[pos++] = blockData[iter][column]; + } + } + } + for (int column = 0; column < b[0]; column++) { + for (int iter = 0; iter < blocks; iter++) { + out[pos++] = blockCheck[iter][column]; + } + } + return out; + } + + /// Mode, length and the bytes, then the terminator and the pad bytes + /// that fill the version's data capacity. + private static byte[] dataStream(byte[] data, int version, int dataBytes) { + Bits bits = new Bits(dataBytes); + bits.add(4, 4); + bits.add(data.length, version < 10 ? 8 : 16); + for (byte one : data) { + bits.add(one & 0xff, 8); + } + int room = dataBytes * 8 - bits.length; + bits.add(0, Math.min(4, room)); + bits.add(0, (8 - bits.length % 8) % 8); + boolean flip = false; + while (bits.length < dataBytes * 8) { + bits.add(flip ? 0x11 : 0xec, 8); + flip = !flip; + } + return bits.bytes; + } + + /// A bit buffer, most significant bit first. + private static final class Bits { + final byte[] bytes; + int length; + + Bits(int capacity) { + bytes = new byte[capacity]; + } + + void add(int value, int count) { + for (int iter = count - 1; iter >= 0; iter--) { + if (((value >>> iter) & 1) != 0) { + bytes[length >>> 3] |= (byte) (0x80 >>> (length & 7)); + } + length++; + } + } + } + + // ------------------------------------------------------- Reed-Solomon + + /// Multiplication in GF(256) with the QR polynomial x^8+x^4+x^3+x^2+1. + static int multiply(int a, int b) { + int out = 0; + for (int iter = 7; iter >= 0; iter--) { + out = (out << 1) ^ ((out >>> 7) * 0x11d); + out ^= ((b >>> iter) & 1) * a; + } + return out & 0xff; + } + + /// The generator polynomial of `degree`, the product of (x - 2^i) for i + /// below it; coefficients from the highest power down, the leading 1 left + /// out. + private static byte[] generator(int degree) { + int[] poly = new int[degree]; + poly[degree - 1] = 1; + int root = 1; + for (int iter = 0; iter < degree; iter++) { + for (int j = 0; j < degree; j++) { + poly[j] = multiply(poly[j], root); + if (j + 1 < degree) { + poly[j] ^= poly[j + 1]; + } + } + root = multiply(root, 2); + } + byte[] out = new byte[degree]; + for (int iter = 0; iter < degree; iter++) { + out[iter] = (byte) poly[iter]; + } + return out; + } + + /// The check bytes: the remainder of `data` times x^degree divided by the + /// generator. + private static byte[] remainder(byte[] data, byte[] generator) { + int[] rest = new int[generator.length]; + for (byte one : data) { + int factor = (one & 0xff) ^ rest[0]; + System.arraycopy(rest, 1, rest, 0, rest.length - 1); + rest[rest.length - 1] = 0; + for (int iter = 0; iter < rest.length; iter++) { + rest[iter] ^= multiply(generator[iter] & 0xff, factor); + } + } + byte[] out = new byte[rest.length]; + for (int iter = 0; iter < rest.length; iter++) { + out[iter] = (byte) rest[iter]; + } + return out; + } + + // ------------------------------------------------------------- layout + + private void set(int x, int y, boolean on) { + dark[y][x] = on; + fixed[y][x] = true; + } + + private void drawPatterns(int version) { + for (int iter = 0; iter < size; iter++) { + set(6, iter, iter % 2 == 0); + set(iter, 6, iter % 2 == 0); + } + finder(3, 3); + finder(size - 4, 3); + finder(3, size - 4); + int[] centres = ALIGN[version - 1]; + int last = centres.length - 1; + for (int i = 0; i <= last; i++) { + for (int j = 0; j <= last; j++) { + boolean onFinder = (i == 0 && j == 0) || (i == 0 && j == last) + || (i == last && j == 0); + if (!onFinder) { + for (int dy = -2; dy <= 2; dy++) { + for (int dx = -2; dx <= 2; dx++) { + set(centres[i] + dx, centres[j] + dy, + Math.max(Math.abs(dx), Math.abs(dy)) != 1); + } + } + } + } + } + // Reserved now, written once the mask is known. + drawFormat(0); + if (version >= 7) { + int bits = version << 12 | bch(version, 0x1f25, 12); + for (int iter = 0; iter < 18; iter++) { + boolean on = ((bits >>> iter) & 1) != 0; + int a = size - 11 + iter % 3; + int b = iter / 3; + set(a, b, on); + set(b, a, on); + } + } + } + + /// A finder with its light separator, clipped at the edge. + private void finder(int cx, int cy) { + for (int dy = -4; dy <= 4; dy++) { + for (int dx = -4; dx <= 4; dx++) { + int x = cx + dx; + int y = cy + dy; + if (x >= 0 && x < size && y >= 0 && y < size) { + int ring = Math.max(Math.abs(dx), Math.abs(dy)); + set(x, y, ring != 2 && ring != 4); + } + } + } + } + + /// The 15 format bits for level M and `mask`, in both copies, and the + /// module that is always dark. + private void drawFormat(int mask) { + int data = LEVEL_M << 3 | mask; + int bits = (data << 10 | bch(data, 0x537, 10)) ^ 0x5412; + for (int iter = 0; iter <= 5; iter++) { + set(8, iter, bit(bits, iter)); + } + set(8, 7, bit(bits, 6)); + set(8, 8, bit(bits, 7)); + set(7, 8, bit(bits, 8)); + for (int iter = 9; iter < 15; iter++) { + set(14 - iter, 8, bit(bits, iter)); + } + for (int iter = 0; iter < 8; iter++) { + set(size - 1 - iter, 8, bit(bits, iter)); + } + for (int iter = 8; iter < 15; iter++) { + set(8, size - 15 + iter, bit(bits, iter)); + } + set(8, size - 8, true); + } + + private static boolean bit(int value, int index) { + return ((value >>> index) & 1) != 0; + } + + /// The BCH remainder of `value` shifted up by `degree`, for the format and + /// version bits. + private static int bch(int value, int poly, int degree) { + int rest = value << degree; + for (int iter = 31 - Integer.numberOfLeadingZeros(rest); iter >= degree; iter--) { + if (((rest >>> iter) & 1) != 0) { + rest ^= poly << (iter - degree); + } + } + return rest; + } + + /// The codewords into every module the patterns left free, two columns at + /// a time from the right, up then down, stepping over the timing column. + private void place(byte[] codewords) { + int total = codewords.length * 8; + int index = 0; + for (int right = size - 1; right >= 1; right -= 2) { + if (right == 6) { + right = 5; + } + boolean upward = ((right + 1) & 2) == 0; + for (int step = 0; step < size; step++) { + int y = upward ? size - 1 - step : step; + for (int j = 0; j < 2; j++) { + int x = right - j; + if (!fixed[y][x] && index < total) { + dark[y][x] = ((codewords[index >>> 3] >>> (7 - (index & 7))) & 1) != 0; + index++; + } + } + } + } + } + + private void applyMask(int mask) { + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + if (!fixed[y][x] && masked(mask, x, y)) { + dark[y][x] = !dark[y][x]; + } + } + } + } + + private static boolean masked(int mask, int x, int y) { + switch (mask) { + case 0: + return (x + y) % 2 == 0; + case 1: + return y % 2 == 0; + case 2: + return x % 3 == 0; + case 3: + return (x + y) % 3 == 0; + case 4: + return (x / 3 + y / 2) % 2 == 0; + case 5: + return x * y % 2 + x * y % 3 == 0; + case 6: + return (x * y % 2 + x * y % 3) % 2 == 0; + default: + return ((x + y) % 2 + x * y % 3) % 2 == 0; + } + } + + // ------------------------------------------------------------ penalty + + /// The standard's four penalty rules: long runs, 2x2 blocks, finder-like + /// patterns, and an imbalance of dark and light. + private int penalty() { + int score = 0; + for (int line = 0; line < size; line++) { + score += runs(line, true) + runs(line, false); + score += finderLike(line, true) + finderLike(line, false); + } + int darkCount = 0; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + if (dark[y][x]) { + darkCount++; + } + if (x + 1 < size && y + 1 < size) { + boolean c = dark[y][x]; + if (c == dark[y][x + 1] && c == dark[y + 1][x] && c == dark[y + 1][x + 1]) { + score += 3; + } + } + } + } + int total = size * size; + // How far the dark share is from half, in whole steps of 5%. + int deviation = Math.abs(darkCount * 20 - total * 10); + score += (deviation / total) * 10; + return score; + } + + private boolean at(int line, int pos, boolean row) { + return row ? dark[line][pos] : dark[pos][line]; + } + + private int runs(int line, boolean row) { + int score = 0; + int run = 1; + for (int pos = 1; pos <= size; pos++) { + if (pos < size && at(line, pos, row) == at(line, pos - 1, row)) { + run++; + } else { + if (run >= 5) { + score += 3 + run - 5; + } + run = 1; + } + } + return score; + } + + /// 1:1:3:1:1 dark-light-dark-light-dark with four light modules on + /// either side; outside the symbol counts as light. + private int finderLike(int line, boolean row) { + int score = 0; + boolean[] pattern = {true, false, true, true, true, false, true}; + for (int start = -4; start + 7 <= size + 4; start++) { + boolean matches = true; + for (int iter = 0; iter < 7 && matches; iter++) { + matches = module(line, start + iter, row) == pattern[iter]; + } + if (!matches) { + continue; + } + if (lightRun(line, start - 4, row) || lightRun(line, start + 7, row)) { + score += 40; + } + } + return score; + } + + private boolean module(int line, int pos, boolean row) { + return pos >= 0 && pos < size && at(line, pos, row); + } + + private boolean lightRun(int line, int from, boolean row) { + for (int iter = from; iter < from + 4; iter++) { + if (module(line, iter, row)) { + return false; + } + } + return true; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/QrView.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/QrView.java new file mode 100644 index 00000000000..30db0f9b401 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/QrView.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; + +/// Draws a [QrCode]: black modules on white with the four-module quiet zone +/// scanners need, whatever the theme -- a QR code in dark-mode colours is +/// one many cameras cannot read. +public final class QrView extends Component { + private static final int QUIET = 4; + private final QrCode code; + + public QrView(QrCode code) { + this.code = code; + setUIID("BnQr"); + } + + @Override + protected Dimension calcPreferredSize() { + int side = Math.min(CN.convertToPixels(45), CN.getDisplayWidth() * 3 / 4); + return new Dimension(side, side); + } + + @Override + public void paint(Graphics g) { + int modules = code.size() + QUIET * 2; + int side = Math.min(getWidth(), getHeight()); + int cell = Math.max(1, side / modules); + int drawn = cell * modules; + int left = getX() + (getWidth() - drawn) / 2; + int top = getY() + (getHeight() - drawn) / 2; + g.setColor(0xffffff); + g.fillRect(left, top, drawn, drawn); + g.setColor(0x000000); + for (int y = 0; y < code.size(); y++) { + for (int x = 0; x < code.size(); x++) { + if (code.isDark(x, y)) { + g.fillRect(left + (x + QUIET) * cell, top + (y + QUIET) * cell, cell, cell); + } + } + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/ReceiptForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/ReceiptForm.java new file mode 100644 index 00000000000..a0cd7319042 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/ReceiptForm.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.bastion.api.AccountDto; +import com.codenameone.examples.bastion.api.ReceiptDto; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import java.util.List; +import com.codenameone.examples.bastion.money.Amounts; + +/// What the server booked: the ledger entry's id is the receipt's reference, +/// the one a support agent would ask for. +public final class ReceiptForm { + /// The memo the app sends with a counter deposit and withdrawal: a code, + /// so the text is the reader's language, not the sender's. + static final String MEMO_DEPOSIT = "@cash-in"; + static final String MEMO_WITHDRAWAL = "@cash-out"; + + private ReceiptForm() { + } + + /// A memo as shown: the app's own codes translated, anything a customer + /// typed as it is. + static String memo(String memo) { + if (MEMO_DEPOSIT.equals(memo)) { + return Lang.tr("Deposit"); + } + if (MEMO_WITHDRAWAL.equals(memo)) { + return Lang.tr("Cash withdrawal"); + } + return memo; + } + + /// The receipt of a movement the user just made. `toName` is who or + /// what it went to, as the screen that sent it named it (a payee's + /// nickname, an account, an address); `done` closes it. + public static void show(final ReceiptDto receipt, String toName, final Runnable done) { + // The end of a flow: a close button, no menu, and a title that says + // what this is rather than what was pressed. + Form form = Ui.finished("Receipt", done); + String asset = receipt.asset(); + Container hero = new Container(BoxLayout.y(), "BnHero"); + hero.add(new Label(Lang.tr("Sent"), "BnHeroLabel")); + hero.add(new Label(Amounts.format(receipt.amount(), asset), "BnHeroAmount")); + hero.add(new Label(Ui.when(receipt.createdAt()), "BnHeroNote")); + form.add(hero); + // Booked is final: the ledger entry exists, and the receipt is its. + form.add(Ui.row(Lang.tr("Status"), null, Lang.tr("Completed"), "BnAmountIn", null)); + final Container from = Ui.row(Lang.tr("From"), null, "", "BnRowValue", null); + form.add(from); + if (toName != null && toName.length() > 0) { + // A name fits beside its label; an address wraps under it. + form.add(toName.length() <= 24 ? Ui.row(Lang.tr("To"), null, toName, null, null) + : Ui.copyable(Lang.tr("To"), toName, false)); + } + form.add(Ui.row(Lang.tr("Amount"), null, Amounts.format(receipt.amount(), asset), null, + null)); + // The bank charges nothing for a movement; the line says so rather + // than leave the reader wondering. + form.add(Ui.row(Lang.tr("Fee"), null, Amounts.format(0L, asset), "BnRowValueMuted", null)); + final Container balance = Ui.row(Lang.tr("Balance after"), null, "", null, null); + form.add(balance); + form.add(Ui.row(Lang.tr("When"), null, Ui.when(receipt.createdAt()), null, null)); + if (receipt.memo() != null && receipt.memo().length() > 0) { + form.add(Ui.fact(Lang.tr("Note"), memo(receipt.memo()))); + } + form.add(Ui.copyable(Lang.tr("Reference"), receipt.entryId(), false)); + // The account's name arrives below; the printout reads it when printed. + final String[] fromName = {""}; + final String[] after = {""}; + final String to = toName == null ? "" : toName; + Button print = Printout.button(() -> printout(receipt, fromName[0], to, after[0])); + Button ok = Ui.primary("Done"); + ok.addActionListener(evt -> done.run()); + form.add(print == null ? ok : Ui.across(print, ok)); + Ui.show(form, true); + // The account it left and what that account holds now. + Api.banking().accounts(Net.to((List accounts) -> { + for (AccountDto account : accounts) { + if (account.id().equals(receipt.fromAccountId())) { + setValue(from, account.name()); + setValue(balance, Amounts.format(account.balance(), account.asset())); + fromName[0] = account.name(); + after[0] = Amounts.format(account.balance(), account.asset()); + } + } + }, (status, message) -> { })); + } + + /// The receipt on paper. + static Printout printout(ReceiptDto receipt, String fromName, String toName, + String balanceAfter) { + String asset = receipt.asset(); + Printout p = new Printout(Lang.tr("Receipt"), System.currentTimeMillis()) + .fact(Lang.tr("Account"), fromName) + .fact(Lang.tr("Reference"), receipt.entryId()) + .fact(Lang.tr("When"), Ui.when(receipt.createdAt())); + p.section(null, new String[] {Lang.tr("Date"), Lang.tr("Description"), Lang.tr("To"), + Lang.tr("Amount")}, new int[] {3, 5, 5, 3}, new boolean[] {false, false, false, true}) + .row(Ui.when(receipt.createdAt()), + receipt.memo() == null ? "" : memo(receipt.memo()), toName, + Amounts.format(receipt.amount(), asset)); + p.total(Lang.tr("Amount"), Amounts.format(receipt.amount(), asset)) + .total(Lang.tr("Fee"), Amounts.format(0L, asset)); + if (balanceAfter != null && balanceAfter.length() > 0) { + p.total(Lang.tr("Balance after"), balanceAfter); + } + return p; + } + + /// Sets the value of a row made by [Ui#row]. + private static void setValue(Container row, String value) { + com.codename1.ui.Component east = row.getLayout() instanceof + com.codename1.ui.layouts.BorderLayout + ? ((com.codename1.ui.layouts.BorderLayout) row.getLayout()).getEast() : null; + if (east instanceof Label) { + ((Label) east).setText(value); + row.revalidate(); + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SecurityCenterForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SecurityCenterForm.java new file mode 100644 index 00000000000..cfb7d6568c2 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SecurityCenterForm.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codenameone.examples.bastion.security.SecurityStatus; + +import java.util.List; + +/// Every protection the app has, and whether it is in force here, now. See +/// [SecurityStatus] for where each answer comes from. +public final class SecurityCenterForm { + private SecurityCenterForm() { + } + + public static void show(final Runnable back) { + Form form = Ui.form("Security Center", back); + form.add(Ui.muted(Lang.tr("What protects your account on this device. " + + "Nothing here is decoration: each line is read from the app as it runs."))); + List items = SecurityStatus.items(); + String group = null; + for (SecurityStatus.Item item : items) { + if (!item.group.equals(group)) { + group = item.group; + form.add(new Label(Lang.tr(group), "BnLabel")); + } + form.add(row(item)); + } + Ui.show(form, false); + } + + private static Container row(SecurityStatus.Item item) { + return Ui.row(Lang.tr(item.name), Lang.tr(item.detail, item.values), + Lang.tr(stateName(item.state)), stateStyle(item.state), null); + } + + static String stateName(int state) { + switch (state) { + case SecurityStatus.ACTIVE: + return "Active"; + case SecurityStatus.SIMULATOR: + return "Not in simulator"; + case SecurityStatus.CLOUD_BUILD: + return "Cloud build"; + case SecurityStatus.WARNING: + return "Warning"; + default: + return "Off"; + } + } + + private static String stateStyle(int state) { + switch (state) { + case SecurityStatus.ACTIVE: + return "BnBadgeGood"; + case SecurityStatus.WARNING: + return "BnBadgeBad"; + default: + return "BnBadgeWarn"; + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SettingsForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SettingsForm.java new file mode 100644 index 00000000000..4c55523ae26 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SettingsForm.java @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.components.SpanLabel; +import com.codenameone.examples.bastion.security.RequestSigner; +import com.codenameone.examples.bastion.api.PasskeyRemoveDto; +import com.codename1.components.Switch; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Command; +import com.codename1.ui.Dialog; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.bastion.api.PasskeyDto; +import com.codenameone.examples.bastion.api.UserDto; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.security.Keys; +import com.codenameone.examples.bastion.security.Lock; + +import java.util.List; + +/// The account's protections the user can change from the app: unlocking +/// with a biometric, the authenticator app, passkeys and this device's key. +public final class SettingsForm { + private SettingsForm() { + } + + public static void show(final Runnable back) { + Form form = Nav.form("Settings", back); + UserDto user = Session.user(); + form.add(Ui.section("Unlock")); + if (Lock.biometricsAvailable()) { + final Switch bio = new Switch(); + bio.setValue(Keys.biometricEnabled()); + final SpanLabel bioError = Ui.error(); + bio.addActionListener(evt -> { + if (bio.isValue()) { + UnlockSetup.biometric(() -> show(back), bioError); + bio.setValue(false); + } else { + Keys.disableBiometric(); + } + }); + Container row = new Container(new BorderLayout(), "BnRow"); + row.add(BorderLayout.CENTER, Ui.wrapped(Lang.tr("Unlock with fingerprint or face"), + "BnRowTitle")); + row.add(BorderLayout.EAST, bio); + form.addAll(row, bioError); + } else { + form.add(Ui.row(Lang.tr("Unlock with fingerprint or face"), + Lang.tr("This device has no biometric enrolled. Set an unlock PIN instead."), + Lang.tr("Off"), "BnBadgeWarn", null)); + } + boolean pin = Keys.pinEnabled(); + form.add(Ui.row(Lang.tr("Unlock PIN"), pin + ? Lang.tr("Opens the lock when a biometric cannot. {0} wrong tries erase the keys " + + "on this device.", String.valueOf(Keys.PIN_ATTEMPTS)) + : Lang.tr("Without a biometric or a PIN the lock asks for your password."), + Lang.tr(pin ? "On" : "Off"), pin ? "BnBadgeGood" : "BnBadgeWarn", + () -> pinDialog(back))); + form.add(Ui.section("Sign-in")); + boolean totp = user != null && user.totpEnabled(); + form.add(Ui.row(Lang.tr("Authenticator app"), totp + ? Lang.tr("{0} recovery codes left", String.valueOf(user.recoveryCodesLeft())) + : Lang.tr("Adds a six-digit code to every password sign-in."), + Lang.tr(totp ? "On" : "Off"), totp ? "BnBadgeGood" : "BnBadgeWarn", + totp ? () -> OnboardingForm.newRecoveryCodes(() -> show(back)) + : () -> OnboardingForm.totp(() -> show(back)))); + final Container passkeys = new Container(BoxLayout.y()); + form.add(passkeys); + loadPasskeys(passkeys); + form.add(Ui.section("This device")); + boolean device = DeviceSetup.registered(); + form.add(Ui.row(Lang.tr("Payment signing key"), Lang.tr(device + ? "Payments from this phone are signed by a key that never leaves it." + : "Register this device to sign payments."), + Lang.tr(device ? "On" : "Off"), device ? "BnBadgeGood" : "BnBadgeWarn", + device ? null : () -> DeviceSetup.register(() -> show(back), + (status, message) -> Ui.toast(message)))); + if (device && com.codenameone.examples.bastion.security.DeviceKey.deviceId() != null) { + // What the server knows this device's key by: the id a support + // agent, or the Security Center on another device, names it with. + form.add(Ui.copyable(Lang.tr("Device key ID"), + com.codenameone.examples.bastion.security.DeviceKey.deviceId(), false)); + } + form.add(Ui.section("Language")); + String speaking = Lang.current(); + for (final String code : Lang.CODES) { + // A language is called what its speakers call it, whatever + // language the rest of the screen is in. + form.add(Ui.row(Lang.name(code), null, code.equals(speaking) ? Lang.tr("On") : null, + "BnBadgeGood", () -> { + Lang.choose(code); + show(back); + })); + } + Button center = Ui.secondary("Security Center"); + center.addActionListener(evt -> SecurityCenterForm.show(() -> show(back))); + form.add(center); + Ui.show(form, false); + } + + // ------------------------------------------------------------------ PIN + + /// Sets, changes or removes the unlock PIN. + private static void pinDialog(final Runnable back) { + final Dialog dialog = new Dialog(Lang.tr("Unlock PIN"), BoxLayout.y()); + final SpanLabel error = Ui.error(); + final UnlockSetup.PinFields fields = new UnlockSetup.PinFields(); + Button save = Ui.primary(Keys.pinEnabled() ? "Change PIN" : "Set PIN"); + save.addActionListener(evt -> fields.save(() -> { + dialog.dispose(); + show(back); + }, error)); + dialog.addAll(error, fields.pin, fields.again, save); + if (Keys.pinEnabled()) { + Button remove = Ui.secondary("Remove PIN"); + remove.addActionListener(evt -> Keys.removePin(() -> { + dialog.dispose(); + show(back); + }, why -> Ui.showError(error, Lang.tr(why)))); + dialog.add(remove); + } + Button cancel = Ui.secondary("Cancel"); + cancel.addActionListener(evt -> dialog.dispose()); + dialog.add(cancel); + dialog.show(); + } + + // ------------------------------------------------------------- passkeys + + private static void loadPasskeys(final Container box) { + Api.account().passkeys(Net.to((List list) -> fillPasskeys(box, list), + (status, message) -> { + box.removeAll(); + box.add(Ui.muted(Lang.tr(message))); + box.revalidate(); + })); + } + + private static void fillPasskeys(final Container box, List list) { + box.removeAll(); + for (final PasskeyDto passkey : list) { + String label = passkey.label() == null || passkey.label().length() == 0 + ? Lang.tr("Passkey") : passkey.label(); + box.add(Ui.row(label, Lang.tr("Last used {0}", Ui.when(passkey.lastUsed())), + Lang.tr("Remove"), "BnBadgeWarn", () -> removePasskey(box, passkey, label))); + box.add(Ui.copyable(Lang.tr("Passkey ID"), passkey.id(), false)); + } + if (Session.passkeysSupported()) { + Button add = Ui.secondary("Add a passkey"); + add.addActionListener(evt -> addPasskey(box)); + box.add(add); + } else if (list.isEmpty()) { + box.add(Ui.muted(Lang.tr("This device cannot create passkeys."))); + } + box.revalidate(); + } + + private static void removePasskey(final Container box, PasskeyDto passkey, String label) { + if (!Dialog.show(Lang.tr("Remove passkey"), + Lang.tr("Remove {0}? It will no longer sign in to this account.", label), + Lang.tr("Remove"), Lang.tr("Cancel"))) { + return; + } + // A step-up, as revoking a device is: signed by this device's key + // when it has one, else (or when that is refused) the password again. + final String id = passkey.id(); + PasskeyRemoveDto signedBody = new PasskeyRemoveDto(null); + String signature = RequestSigner.sign("POST", "/api/account/passkeys/" + id + "/remove", + null, signedBody); + if (signature == null) { + removePasskeyWithPassword(box, id); + return; + } + Api.account().removePasskey(id, signature, signedBody, Net.to( + (List left) -> fillPasskeys(box, left), + (status, message) -> { + if (status == 403) { + removePasskeyWithPassword(box, id); + } else { + Ui.toast(Lang.tr(message)); + } + })); + } + + private static void removePasskeyWithPassword(final Container box, String id) { + TextField password = new TextField("", Lang.tr("Password"), 20, TextArea.PASSWORD); + password.setUIID("BnField"); + Command ok = new Command(Lang.tr("Continue")); + if (Dialog.show(Lang.tr("Confirm it is you"), + // Enclosed, or the dialog stretches the one-line field to its + // whole body. + com.codename1.ui.layouts.BoxLayout.encloseY(password), ok, + new Command(Lang.tr("Cancel"))) != ok) { + return; + } + Api.account().removePasskey(id, null, new PasskeyRemoveDto(password.getText()), Net.to( + (List left) -> fillPasskeys(box, left), + (status, message) -> Ui.toast(Lang.tr(message)))); + } + + /// A passkey is registered against a fresh signed-in server session, not + /// the API token (see `Session.addPasskey`), so this signs in once more -- + /// password, and the app's code when the account has one -- keeping that + /// session for the one ceremony. + private static void addPasskey(final Container box) { + final UserDto user = Session.user(); + if (user == null) { + return; + } + TextField password = new TextField("", Lang.tr("Password"), 20, TextArea.PASSWORD); + password.setUIID("BnField"); + Command ok = new Command(Lang.tr("Continue")); + if (Dialog.show(Lang.tr("Confirm it is you"), + // Enclosed, or the dialog stretches the one-line field to its + // whole body. + com.codename1.ui.layouts.BoxLayout.encloseY(password), ok, + new Command(Lang.tr("Cancel"))) != ok) { + return; + } + final Net.Failed failed = (status, message) -> { + Session.endPasskeySession(); + Ui.toast(Lang.tr(message)); + }; + final Runnable ceremony = () -> Session.addPasskey( + Lang.tr("Bastion on {0}", CN.getPlatformName()), () -> loadPasskeys(box), failed); + Session.keepSessionForPasskey(); + Session.signIn(user.username(), password.getText(), pending -> { + TextField code = new TextField("", Lang.tr("Code from your app"), 10, TextArea.NUMERIC); + code.setUIID("BnField"); + Command go = new Command(Lang.tr("Continue")); + if (Dialog.show(Lang.tr("Authenticator code"), code, go, + new Command(Lang.tr("Cancel"))) != go) { + Session.endPasskeySession(); + return; + } + Session.submitCode(pending, code.getText(), ceremony, failed); + }, ceremony, failed); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SignInForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SignInForm.java new file mode 100644 index 00000000000..448cc49a284 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SignInForm.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.security.Keys; +import com.codenameone.examples.bastion.security.Lock; + +/// Signing in, with the strongest way this device has offered first: +/// +/// 1. **A passkey** whose authenticator verifies the user -- a fingerprint, +/// a face, the device PIN -- in one step ([Session#signInWithPasskey]). +/// The server asks for that verification on every passkey sign-in. +/// 2. **This device's key, opened by a biometric**: a session stored on this +/// device is sealed under the app key, which the OS releases only after a +/// biometric check (the lock screen); the device key that signs payments +/// is sealed under the same key. +/// 3. **E-mail and password**, then -- for an account with an authenticator +/// app -- the six-digit code. +/// +/// The server holds an account to the strongest factor it has enrolled: once +/// it has a passkey, the password is refused, and the code step after it +/// takes a recovery code and nothing else. That is the documented way back +/// for a customer whose passkey is gone, and the messages below say so. +public final class SignInForm { + /// The ways in, strongest first ([#strongest]). + static final int PASSKEY = 0; + static final int DEVICE = 1; + static final int PASSWORD = 2; + + private SignInForm() { + } + + /// The strongest way in this device offers: a passkey where the platform + /// has a passkey UI, else a stored session a biometric opens, else the + /// password. + static int strongest(boolean passkeys, boolean biometricSession) { + if (passkeys) { + return PASSKEY; + } + return biometricSession ? DEVICE : PASSWORD; + } + + /// Whether a session is stored on this device behind a biometric. + static boolean biometricSession() { + return Session.hasStoredSession() && Keys.exists() && Keys.biometricEnabled(); + } + + public static void show(final Runnable signedIn) { + Container form = Ui.landing("Sign in", "Bastion", "Your bank, locked down."); + form.add(new Label(Lang.tr("Sign in"), "BnLandingHeading")); + final SpanLabel error = Ui.error(); + boolean passkeys = Session.passkeysSupported(); + boolean device = biometricSession(); + int best = strongest(passkeys, device); + if (passkeys) { + Button passkey = best == PASSKEY ? Ui.primary("Sign in with a passkey") + : Ui.secondary("Sign in with a passkey"); + passkey.addActionListener(evt -> { + Ui.showError(error, null); + Session.signInWithPasskey(signedIn, + (status, message) -> Ui.showError(error, Lang.tr(message))); + }); + form.add(passkey); + } + if (device) { + Button unlock = best == DEVICE ? Ui.primary("Unlock with fingerprint or face") + : Ui.secondary("Unlock with fingerprint or face"); + // The lock screen asks for the biometric, opens the app key and + // with it the stored session and this device's signing key. + unlock.addActionListener(evt -> Lock.lockAtStart()); + form.add(unlock); + } + if (best != PASSWORD) { + form.add(Ui.muted(Lang.tr("Or sign in with your e-mail and password. An account " + + "with a passkey takes its password only with a recovery code."))); + } + final TextField email = Ui.field("E-mail", TextArea.EMAILADDR); + final TextField password = Ui.field("Password", TextArea.PASSWORD); + final Button go = best == PASSWORD ? Ui.primary("Sign in") : Ui.secondary("Sign in"); + form.addAll(email, password, error, go); + go.addActionListener(evt -> { + go.setEnabled(false); + Ui.showError(error, null); + Session.signIn(email.getText(), password.getText(), + pending -> codeStep(pending, signedIn), + signedIn, + (status, message) -> { + go.setEnabled(true); + Ui.showError(error, Lang.tr(message)); + }); + // The password is not kept in the field once it has been sent. + password.setText(""); + }); + Button join = Ui.secondary("Open an account"); + join.addActionListener(evt -> OnboardingForm.show(() -> show(signedIn), signedIn)); + form.add(join); + Ui.show(form.getComponentForm(), false); + } + + private static void codeStep(final Session.Pending pending, final Runnable signedIn) { + Container form = Ui.landing("Verification", "Enter your code", null); + form.add(Ui.muted(Lang.tr("Open your authenticator app and enter the six-digit code, " + + "or one of your recovery codes."))); + final TextField code = Ui.field("Code", TextArea.ANY); + final SpanLabel error = Ui.error(); + final Button go = Ui.primary("Continue"); + form.addAll(code, error, go); + go.addActionListener(evt -> { + go.setEnabled(false); + Session.submitCode(pending, code.getText(), signedIn, (status, message) -> { + go.setEnabled(true); + code.setText(""); + Ui.showError(error, Lang.tr(message)); + }); + }); + if (Session.passkeysSupported()) { + Button passkey = Ui.secondary("Sign in with a passkey"); + passkey.addActionListener(evt -> Session.signInWithPasskey(signedIn, + (status, message) -> Ui.showError(error, Lang.tr(message)))); + form.add(passkey); + } + Ui.show(form.getComponentForm(), true); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Table.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Table.java new file mode 100644 index 00000000000..ca04e059f0f --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Table.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.table.TableLayout; + +/// A table of the console: a header row and rows of cells, the widths shared +/// out by weight, numbers aligned to the end of their column. +/// +/// "End" follows the language: in Hebrew the layout mirrors, so the first +/// column is on the right and the amounts line up on the left edge of +/// theirs, which is how a Hebrew ledger reads. +/// +/// On a narrow screen a table wider than the window scrolls sideways inside +/// itself rather than squeezing seven columns into a phone's width. +public final class Table { + /// A table is never drawn narrower than this, in millimetres per weight. + private static final float MM_PER_WEIGHT = 9f; + + private final String[] headers; + private final int[] weights; + private final boolean[] numeric; + private final Container rows = new Container(BoxLayout.y()); + private final Container outer; + /// The same rows, for a printout of the page. + private final Printout.Section printable; + + /// @param headers the column names, translated here + /// @param weights each column's share of the width + /// @param numeric which columns hold numbers (aligned to the end) + public Table(String[] headers, int[] weights, boolean[] numeric) { + this(headers, weights, numeric, false); + } + + /// @param fit true for a table whose columns were chosen to fit the + /// window: it never scrolls sideways + public Table(String[] headers, int[] weights, boolean[] numeric, boolean fit) { + this.headers = headers; + this.weights = weights; + this.numeric = numeric; + int total = 0; + for (int iter = 0; iter < weights.length; iter++) { + total += weights[iter]; + } + Container head = line("BnTableHead"); + String[] translated = new String[headers.length]; + for (int iter = 0; iter < headers.length; iter++) { + translated[iter] = Lang.tr(headers[iter]); + } + printable = new Printout.Section(null, translated, weights, numeric); + for (int iter = 0; iter < headers.length; iter++) { + head.add(constraint(head, iter), cell(Lang.tr(headers[iter]), + numeric[iter] ? "BnCellHeadNum" : "BnCellHead")); + } + final int least = CN.convertToPixels(MM_PER_WEIGHT * total); + final boolean scrolls = !fit && least > CN.getDisplayWidth() - CN.convertToPixels(8f); + // The width is set in calcPreferredSize, never with setPreferredW, + // which would freeze the height at the header's while rows arrive. + Container body = new Container(BoxLayout.y(), "BnTable") { + @Override + protected Dimension calcPreferredSize() { + Dimension d = super.calcPreferredSize(); + if (scrolls) { + d.setWidth(Math.max(d.getWidth(), least)); + } + return d; + } + }; + body.add(head); + body.add(rows); + // A BorderLayout centre is held to the window's width, so a table + // that scrolls sits in a row that lets it be as wide as it asks. + outer = new Container(scrolls ? BoxLayout.x() : new BorderLayout()); + outer.setScrollableX(scrolls); + if (scrolls) { + outer.add(body); + } else { + outer.add(BorderLayout.CENTER, body); + } + } + + /// The table, for the page to place. + public Container component() { + return outer; + } + + /// Adds a row; `tap` (may be null) runs when it is tapped. + public Container add(String[] cells, Runnable tap) { + return add(cells, "BnTableRow", null, tap); + } + + /// Adds a row in `uiid` (a highlighted row is `BnTableRowBad`); `cellUiids` + /// may name a style per cell, or be null. + public Container add(String[] cells, String uiid, String[] cellUiids, final Runnable tap) { + if ("BnTableTotal".equals(uiid)) { + printable.total(cells); + } else { + printable.row(cells); + } + Container row = line(uiid); + for (int iter = 0; iter < cells.length; iter++) { + String style = cellUiids != null && cellUiids[iter] != null ? cellUiids[iter] + : numeric[iter] ? "BnCellNum" : "BnCell"; + row.add(constraint(row, iter), cell(cells[iter] == null ? "" : cells[iter], style)); + } + if (tap == null) { + rows.add(row); + return row; + } + Button lead = new Button(); + lead.setUIID("BnRowLead"); + lead.addActionListener(evt -> tap.run()); + Container whole = new Container(new BorderLayout()); + whole.add(BorderLayout.CENTER, row); + whole.add(BorderLayout.WEST, lead); + whole.setLeadComponent(lead); + rows.add(whole); + return row; + } + + /// A row of quiet text across the whole table. + public void note(String text) { + printable.note(text); + Container row = new Container(new BorderLayout(), "BnTableRow"); + row.add(BorderLayout.CENTER, new Label(text, "BnCellMuted")); + rows.add(row); + } + + /// The table as it is printed ([Printout#add]). + public Printout.Section printable() { + return printable; + } + + public int size() { + return rows.getComponentCount(); + } + + private Container line(String uiid) { + TableLayout layout = new TableLayout(1, headers.length); + layout.setGrowHorizontally(true); + return new Container(layout, uiid); + } + + private Object constraint(Container row, int column) { + if (!(row.getLayout() instanceof TableLayout)) { + return null; + } + TableLayout.Constraint c = ((TableLayout) row.getLayout()).createConstraint(); + int total = 0; + for (int iter = 0; iter < weights.length; iter++) { + total += weights[iter]; + } + // The percentages are whole numbers; the last column takes the rest. + int used = 0; + for (int iter = 0; iter < column; iter++) { + used += weights[iter] * 100 / total; + } + c.setWidthPercentage(column == weights.length - 1 ? 100 - used + : weights[column] * 100 / total); + return c; + } + + private static Component cell(String text, String uiid) { + Label label = new Label(text, uiid); + label.setEndsWith3Points(true); + return label; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/TransferForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/TransferForm.java new file mode 100644 index 00000000000..1b6dcf9f6de --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/TransferForm.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.spinner.Picker; +import com.codenameone.examples.bastion.api.AccountDto; +import com.codenameone.examples.bastion.api.PayeeDto; +import com.codenameone.examples.bastion.api.ReceiptDto; +import com.codenameone.examples.bastion.api.TransferDto; +import com.codenameone.examples.bastion.money.Amounts; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import com.codenameone.examples.bastion.security.RequestSigner; +import java.util.ArrayList; +import java.util.List; + +/// Moving money: between the user's own accounts, or to a payee. +/// +/// Three things make this screen safe to press twice, or to lose the network +/// on: +/// +/// - **An idempotency key per payment**, made when the user reviews it and +/// sent with every try of it. A retry after a lost answer is answered with +/// the first receipt; the money moves once. +/// - **A device signature** ([RequestSigner]) over the exact body sent. The +/// server wants one for anything leaving the customer, and -- above a +/// threshold -- for a move between their own accounts too. Without a +/// registered device the refusal is turned into an offer to register. +/// - **The server's word on limits.** A new payee may receive only so much +/// while it cools off, a frozen account sends nothing, a device that lost +/// its trust signs nothing; each is a refusal with its reason, shown as it +/// is rather than second-guessed here. +public final class TransferForm { + private static final String TARGET = "/api/transfers"; + + private TransferForm() { + } + + /// `fromId` preselects the paying account (null: the first); `toPayee` + /// opens on paying someone rather than a move between own accounts. + public static void show(final String fromId, final boolean toPayee, final Runnable back) { + Form form = Nav.form(toPayee ? "Pay someone" : "Move money", back); + final Container content = new Container(BoxLayout.y()); + form.add(content); + Ui.show(form, true); + load(content, fromId, toPayee, back); + } + + private static void load(final Container content, final String fromId, final boolean toPayee, + final Runnable back) { + Ui.loading(content); + Api.banking().accounts(Net.to((List accounts) -> + Api.banking().payees(Net.to((List payees) -> + fill(content, accounts, payees, fromId, toPayee, back), + (status, message) -> Ui.failed(content, message, + () -> load(content, fromId, toPayee, back)))), + (status, message) -> Ui.failed(content, message, + () -> load(content, fromId, toPayee, back)))); + } + + /// The account a move from `fromId` most likely goes to: the first other + /// account in the same currency (a move cannot change currency; that is a + /// trade), else the first other account at all. + static String partner(List accounts, String fromId) { + String asset = null; + for (AccountDto account : accounts) { + if (account.id().equals(fromId)) { + asset = account.asset(); + } + } + String other = null; + for (AccountDto account : accounts) { + if (!account.id().equals(fromId)) { + if (account.asset().equals(asset)) { + return account.id(); + } + if (other == null) { + other = account.id(); + } + } + } + return other != null ? other : fromId; + } + + private static void fill(final Container content, final List all, + final List payees, String fromId, boolean toPayee, final Runnable back) { + content.removeAll(); + // Only accounts that can send: not frozen, and holding something. + final List accounts = new ArrayList(); + int initial = 0; + for (AccountDto account : all) { + if (!account.frozen()) { + if (account.id().equals(fromId)) { + initial = accounts.size(); + } + accounts.add(account); + } + } + if (accounts.isEmpty()) { + content.add(Ui.empty("You have no account that can send money.")); + content.revalidate(); + return; + } + final String[] fromLabels = labels(accounts); + final String[] accountIds = ids(accounts); + final Picker from = Ui.picker(accountIds, fromLabels, accountIds[Math.min(initial, accountIds.length - 1)]); + final Container target = new Container(BoxLayout.y()); + final Picker[] to = new Picker[1]; + final boolean[] payee = {toPayee}; + final String[][] toLabels = new String[1][]; + final String[][] toIds = new String[1][]; + Container mode = Ui.segments(new String[] {"My accounts", "A payee"}, toPayee ? 1 : 0, + index -> { + payee[0] = index == 1; + target.removeAll(); + toLabels[0] = payee[0] ? payeeLabels(payees) : labels(accounts); + toIds[0] = payee[0] ? payeeIds(payees) : accountIds; + if (toLabels[0].length == 0) { + target.add(Ui.empty("Add a payee first, under Payees.")); + to[0] = null; + } else { + to[0] = Ui.picker(toIds[0], toLabels[0], + payee[0] ? toIds[0][0] : partner(accounts, Ui.pickedId(from))); + target.add(to[0]); + } + target.revalidate(); + }); + final TextField amount = Ui.field("Amount", TextField.DECIMAL); + final TextField memo = Ui.field("Note (optional)", TextField.ANY); + final SpanLabel error = Ui.error(); + final Button review = Ui.primary("Review"); + // The payment last tried and its key: pressing Send again for the + // same payment -- after a timeout, say -- reuses the key, so the + // server answers with the first receipt if the first try got through. + final TransferDto[] tried = new TransferDto[1]; + final String[] key = new String[1]; + content.add(mode); + content.add(Ui.section("From")); + content.add(from); + content.add(Ui.section("To")); + content.add(target); + content.add(Ui.section("Amount")); + content.addAll(amount, memo, error, review); + review.addActionListener(evt -> { + Ui.showError(error, null); + AccountDto source = accounts.get(Math.max(0, Ui.indexOf(accountIds, Ui.pickedId(from)))); + if (to[0] == null) { + Ui.showError(error, Lang.tr("Choose who to pay.")); + return; + } + int picked = Ui.indexOf(toIds[0], Ui.pickedId(to[0])); + if (picked < 0) { + Ui.showError(error, Lang.tr("Choose who to pay.")); + return; + } + long units = Amounts.parse(amount.getText(), source.asset()); + if (units <= 0) { + Ui.showError(error, Ui.amountHint()); + return; + } + if (units > source.balance()) { + Ui.showError(error, Lang.tr("That is more than this account holds.")); + return; + } + String toId = null; + String payeeId = null; + String toName; + if (payee[0]) { + PayeeDto p = payees.get(picked); + payeeId = p.id(); + toName = p.nickname(); + } else { + AccountDto dest = accounts.get(picked); + if (dest.id().equals(source.id())) { + Ui.showError(error, Lang.tr("Choose two different accounts.")); + return; + } + toId = dest.id(); + toName = dest.name(); + } + String note = memo.getText().trim(); + TransferDto transfer = new TransferDto(source.id(), toId, payeeId, units, + note.length() == 0 ? null : note); + if (!Ui.confirm("Confirm", Lang.tr("Send {0} from {1} to {2}?", + Amounts.format(units, source.asset()), source.name(), toName), "Send")) { + return; + } + if (tried[0] == null || !tried[0].equals(transfer)) { + tried[0] = transfer; + key[0] = RequestSigner.idempotencyKey(); + } + send(review, error, transfer, toName, key[0], back); + }); + content.revalidate(); + } + + /// Sends `transfer`; `key` is the payment's idempotency key and stays the + /// same for the retry that follows registering the device. + private static void send(final Button review, final SpanLabel error, final TransferDto transfer, + final String toName, final String key, final Runnable back) { + review.setEnabled(false); + DeviceSetup.withDevice(failed -> Api.banking().transfer(key, + RequestSigner.sign("POST", TARGET, key, transfer), transfer, + Net.to((ReceiptDto receipt) -> ReceiptForm.show(receipt, toName, back), failed)), + (status, message) -> { + review.setEnabled(true); + Ui.showError(error, Lang.tr(message)); + }); + } + + static String[] labels(List accounts) { + String[] out = new String[accounts.size()]; + for (int iter = 0; iter < out.length; iter++) { + AccountDto a = accounts.get(iter); + out[iter] = a.name() + " (" + Amounts.format(a.balance(), a.asset()) + ")"; + } + return out; + } + + static String[] ids(List accounts) { + String[] out = new String[accounts.size()]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = accounts.get(iter).id(); + } + return out; + } + + private static String[] payeeIds(List payees) { + String[] out = new String[payees.size()]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = payees.get(iter).id(); + } + return out; + } + + private static String[] payeeLabels(List payees) { + String[] out = new String[payees.size()]; + for (int iter = 0; iter < out.length; iter++) { + PayeeDto p = payees.get(iter); + out[iter] = p.nickname() + " - " + p.email(); + } + return out; + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Ui.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Ui.java new file mode 100644 index 00000000000..23bbb1cd2c2 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Ui.java @@ -0,0 +1,609 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.components.InfiniteProgress; +import com.codename1.components.SpanLabel; +import com.codename1.components.ToastBar; +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Font; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.animations.CommonTransitions; +import com.codename1.ui.animations.Transition; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.Toolbar; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.spinner.Picker; +import java.util.Date; +import com.codenameone.examples.bastion.money.Amounts; +import com.codenameone.examples.bastion.security.Lock; +import com.codenameone.examples.bastion.security.Protect; + +/// The few building blocks every screen is made of; the look of each is a +/// style in `src/main/css/theme.css`. +public final class Ui { + private Ui() { + } + + /// A scrolling form of one column. + public static Form form(String title) { + Form form = new Form(Lang.tr(title), BoxLayout.y()); + form.setUIID("BnForm"); + // A value marked selectable (Ui.copyable) can be selected and copied. + form.getTextSelection().setEnabled(true); + form.getContentPane().setUIID("BnPage"); + form.setScrollableY(true); + return form; + } + + /// Shows `form`. A `sensitive` one -- anything with a balance, a number + /// or a code on it -- blocks screenshots while it is up; the form + /// remembers it, so going back to it ([#reshow]) blocks them again. + /// + /// While the app is locked nothing but the lock screen is shown: a form + /// an answer builds after the lock is kept for the unlock instead + /// (`Lock.defer`), never painted over the lock. + public static void show(Form form, boolean sensitive) { + form.putClientProperty(SENSITIVE, sensitive ? Boolean.TRUE : Boolean.FALSE); + if (Lock.isLocked()) { + Lock.defer(form); + return; + } + display(form, sensitive); + } + + /// Shows a form shown before, with the protection it was shown with. + public static void reshow(Form form) { + show(form, isSensitive(form)); + } + + /// Whether `form` was shown as sensitive. A form this class never saw is + /// treated as sensitive: the safe answer. + public static boolean isSensitive(Form form) { + return !Boolean.FALSE.equals(form.getClientProperty(SENSITIVE)); + } + + /// The lock screen itself: the one form shown while locked. + static void showLockScreen(Form form) { + form.putClientProperty(SENSITIVE, Boolean.TRUE); + // No transition in either direction: a slide or fade would paint the + // form being locked, balances and all, while the lock comes in. + Form under = CN.getCurrentForm(); + Transition out = under == null ? null : under.getTransitionOutAnimator(); + if (under != null) { + under.setTransitionOutAnimator(CommonTransitions.createEmpty()); + } + form.setTransitionInAnimator(CommonTransitions.createEmpty()); + try { + display(form, true); + } finally { + if (under != null) { + under.setTransitionOutAnimator(out); + } + } + } + + private static void display(Form form, boolean sensitive) { + Protect.screen(sensitive); + Lock.watch(form); + form.show(); + } + + private static final String SENSITIVE = "BnSensitive"; + + public static Label title(String text) { + return new Label(Lang.tr(text), "BnTitle"); + } + + public static SpanLabel text(String text) { + SpanLabel label = new SpanLabel(text); + label.setTextUIID("BnText"); + return label; + } + + public static SpanLabel muted(String text) { + SpanLabel label = new SpanLabel(text); + label.setTextUIID("BnMuted"); + return label; + } + + /// An error or status line. It wraps rather than end in "...": a message + /// cut short is one the reader cannot act on. + public static SpanLabel error() { + SpanLabel label = wrapped("", "BnError"); + label.setVisible(false); + return label; + } + + /// A sentence in `uiid` that wraps over as many lines as it needs. + public static SpanLabel wrapped(String text, String uiid) { + SpanLabel label = new SpanLabel(text); + label.setTextUIID(uiid); + return label; + } + + public static void showError(SpanLabel label, String message) { + label.setText(message); + label.setVisible(message != null && message.length() > 0); + Form form = label.getComponentForm(); + if (form != null) { + form.revalidate(); + } + } + + public static Button primary(String text) { + return new Button(Lang.tr(text), "BnPrimary"); + } + + public static Button secondary(String text) { + return new Button(Lang.tr(text), "BnSecondary"); + } + + public static TextField field(String hint, int constraint) { + TextField field = new TextField("", Lang.tr(hint), 20, constraint); + field.setUIID("BnField"); + return field; + } + + public static Component spacer() { + return new Label(" ", "BnSpacer"); + } + + /// A screen of the signed-out app -- sign in, the code step, the lock -- + /// with no title bar: the brand band across the top is its header, as + /// the front door of a bank app usually is. Answers the column the + /// screen's fields go in; the form is its `getComponentForm()`. + public static Container landing(String title, String heading, String subtitle) { + Form form = new Form(Lang.tr(title), new BorderLayout()); + form.setUIID("BnForm"); + // A value marked selectable (Ui.copyable) can be selected and copied. + form.getTextSelection().setEnabled(true); + form.getToolbar().hideToolbar(); + Container band = new Container(BoxLayout.y(), "BnBrandBand"); + band.setSafeArea(true); + Label logo = new Label("", "BnBrandLogo"); + FontImage.setMaterialIcon(logo, FontImage.MATERIAL_SHIELD, 9f); + band.add(logo); + // Wraps rather than ending in "...": a longer translation, or a + // narrow phone, gets a second line. + SpanLabel headline = new SpanLabel(Lang.tr(heading)); + headline.setTextUIID("BnBrandTitle"); + band.add(headline); + if (subtitle != null) { + SpanLabel line = new SpanLabel(Lang.tr(subtitle)); + line.setTextUIID("BnBrandText"); + band.add(line); + } + Container column = new Container(BoxLayout.y(), "BnPage"); + column.setScrollableY(true); + form.add(BorderLayout.NORTH, band); + form.add(BorderLayout.CENTER, column); + return column; + } + + /// A form with a back arrow that runs `back`. Whether it also has the + /// menu is [Nav#form]'s question; the answer there is "never both". + public static Form form(String title, final Runnable back) { + Form form = form(title); + if (back != null) { + form.getToolbar().setBackCommand("", Toolbar.BackCommandPolicy.AS_ARROW, evt -> back.run()); + } + return form; + } + + /// A small heading over a group of rows. + public static Label section(String text) { + return new Label(Lang.tr(text), "BnLabel"); + } + + /// A row: a title and a line under it on the left, `value` on the right + /// in `valueUiid`. Tapping it runs `tap`, when there is one. + public static Container row(String title, String detail, String value, String valueUiid, + final Runnable tap) { + Container words = new Container(BoxLayout.y()); + words.add(new Label(title, "BnRowTitle")); + if (detail != null && detail.length() > 0) { + SpanLabel line = new SpanLabel(detail); + line.setTextUIID("BnRowDetail"); + words.add(line); + } + Container row = new Container(new BorderLayout(), "BnRow"); + row.add(BorderLayout.CENTER, words); + if (value != null) { + row.add(BorderLayout.EAST, valueUiid != null && valueUiid.startsWith("BnBadge") + ? chip(value, valueUiid) : new Label(value, "BnRowValue")); + } + if (tap != null) { + row.setLeadComponent(lead(row, tap)); + } + return row; + } + + /// A status pill, as tall as its text and centred on the row beside it. + /// Put straight into a border layout's side a label stretches to the + /// row's whole height, which made a tall coloured block of every status. + public static Container chip(String text, String uiid) { + Container box = new Container(new FlowLayout(Component.CENTER, Component.CENTER)); + box.add(new Label(text, uiid)); + return box; + } + + /// An invisible button that makes a whole row one tap target. + private static Button lead(Container row, final Runnable tap) { + Button lead = new Button(); + lead.setUIID("BnRowLead"); + lead.addActionListener(evt -> tap.run()); + row.add(BorderLayout.WEST, lead); + return lead; + } + + /// A line of quiet text for a list with nothing in it yet. + public static SpanLabel empty(String text) { + SpanLabel label = new SpanLabel(Lang.tr(text)); + label.setTextUIID("BnEmpty"); + return label; + } + + /// Buttons side by side, sharing the width, with the theme's gap + /// (`bnButtonGapMm`) between each two. The label size is the theme's and + /// the same for every button: nothing here shrinks one label to fit. + /// + /// When the buttons do not fit one row at that size -- three on a phone, + /// or a long translation -- the first, the screen's main action, takes a + /// row of its own and the rest share the row under it; two that still do + /// not fit are stacked. Wrapping beats a label ending in "...". + public static Container across(Component... parts) { + int gap = CN.convertToPixels(buttonGapMm()); + // A grid gives every cell the same width, so the widest button + // decides whether the row fits. + int widest = 0; + for (Component part : parts) { + widest = Math.max(widest, part.getPreferredW()); + } + int needed = widest * parts.length + gap * (parts.length - 1); + // The page's own side padding is about 4mm each side. + int room = CN.getDisplayWidth() - CN.convertToPixels(8f); + if (needed <= room || parts.length < 2) { + return gridRow(gap, parts); + } + Container column = new Container(BoxLayout.y()); + if (parts.length > 2) { + Component[] rest = new Component[parts.length - 1]; + System.arraycopy(parts, 1, rest, 0, rest.length); + column.add(parts[0]); + column.add(across(rest)); + return column; + } + column.addAll(parts); + return column; + } + + /// One grid row of `parts`, `gap` pixels apart. + private static Container gridRow(int gap, Component... parts) { + Container row = new Container(new GridLayout(1, parts.length)); + row.addAll(parts); + gaps(row, gap); + return row; + } + + /// Spaces the children of a one-row grid, or of a column, the standard + /// button gap apart. Call it again after refilling the container. + public static void gaps(Container row) { + gaps(row, CN.convertToPixels(buttonGapMm())); + } + + /// A grid has no gap of its own, so each cell gives up half of it on each + /// inner side; the margins are start and end ones, which the RTL layout + /// mirrors with the grid. A column gets the gap above every child but the + /// first. + private static void gaps(Container row, int gap) { + boolean across = row.getLayout() instanceof GridLayout; + int count = row.getComponentCount(); + int half = gap / 2; + for (int iter = 0; iter < count; iter++) { + com.codename1.ui.plaf.Style style = row.getComponentAt(iter).getAllStyles(); + if (across) { + style.setMarginUnitLeft(com.codename1.ui.plaf.Style.UNIT_TYPE_PIXELS); + style.setMarginUnitRight(com.codename1.ui.plaf.Style.UNIT_TYPE_PIXELS); + style.setMarginLeft(iter == 0 ? 0 : half); + style.setMarginRight(iter == count - 1 ? 0 : gap - half); + } else { + style.setMarginUnitTop(com.codename1.ui.plaf.Style.UNIT_TYPE_PIXELS); + style.setMarginTop(iter == 0 ? 0 : gap); + } + } + } + + private static float buttonGapMm() { + String value = com.codename1.ui.plaf.UIManager.getInstance().getThemeConstant( + "bnButtonGapMm", "2"); + try { + return Float.parseFloat(value); + } catch (NumberFormatException e) { + return 2f; + } + } + + /// A key or code shown to be copied by eye: its groups of four (split at + /// the spaces of `grouped`), each in a monospaced box, wrapping between + /// groups and never inside one -- so it is always shown whole. + public static Container keyGroups(String grouped) { + Container box = new Container(new com.codename1.ui.layouts.FlowLayout(Component.CENTER)); + Font mono = Font.createSystemFont(Font.FACE_MONOSPACE, Font.STYLE_BOLD, Font.SIZE_LARGE); + int start = 0; + while (start < grouped.length()) { + int end = grouped.indexOf(' ', start); + if (end < 0) { + end = grouped.length(); + } + if (end > start) { + Label group = new Label(grouped.substring(start, end), "BnKeyGroup"); + group.getAllStyles().setFont(mono); + box.add(group); + } + start = end + 1; + } + return box; + } + + /// A fact whose value may be long -- a reference, an address: the label + /// on a line of its own and the value wrapping in full under it, never + /// cut off. + public static Container fact(String title, String value) { + Container row = new Container(BoxLayout.y(), "BnRow"); + row.add(new Label(title, "BnRowTitle")); + SpanLabel text = new SpanLabel(value); + text.setTextUIID("BnRowValueWrap"); + row.add(text); + return row; + } + + /// A value someone will want somewhere else -- a reference, an address, a + /// key, a hash: the label on a line of its own, the value wrapping in full + /// under it and selectable, and a copy button beside it. + /// + /// A `secret` (a key, a recovery code, an account or card number) is + /// copied through [Protect#copy], as a sensitive clip the platform keeps + /// out of its clipboard history and that clears itself after + /// `AppConfig.CLIPBOARD_CLEAR_MILLIS`; anything else is an ordinary copy. + /// Either way a toast says it was copied. + public static Container copyable(String title, final String value, final boolean secret) { + Container row = new Container(new BorderLayout(), "BnRow"); + Container text = new Container(BoxLayout.y()); + text.add(new Label(title, "BnRowTitle")); + SpanLabel shown = new SpanLabel(value == null ? "" : value); + shown.setTextUIID("BnRowValueWrap"); + shown.setTextSelectionEnabled(true); + text.add(shown); + row.add(BorderLayout.CENTER, text); + row.add(BorderLayout.EAST, copyButton(title, value, secret)); + return row; + } + + /// The copy button of [#copyable], for a value shown some other way + /// (`keyGroups`, a QR code). + public static Button copyButton(String title, final String value, final boolean secret) { + Button copy = new Button("", "BnIconButton"); + FontImage.setMaterialIcon(copy, FontImage.MATERIAL_CONTENT_COPY, 4f); + copy.setAccessibilityText(Lang.tr("Copy {0}", title)); + copy.addActionListener(evt -> copy(value, secret)); + return copy; + } + + /// Copies `value` and says so; see [#copyable]. + public static void copy(String value, boolean secret) { + if (value == null || value.length() == 0) { + return; + } + if (secret) { + Protect.copy(value); + toast("Copied. The clipboard clears itself in a minute."); + } else { + Display.getInstance().copyToClipboard(value); + toast("Copied"); + } + } + + /// A screen at the end of a flow -- a receipt: no menu and no back arrow + /// into the flow just finished, but a close button that runs `close` + /// (the device's back button does the same). + public static Form finished(String title, final Runnable close) { + Form form = form(title); + com.codename1.ui.Command done = com.codename1.ui.Command.createMaterial("", + FontImage.MATERIAL_CLOSE, evt -> close.run()); + form.getToolbar().addCommandToLeftBar(done); + form.setBackCommand(done); + return form; + } + + /// A choice among `labels`, shown as segments; `chosen` is told the index + /// of the one tapped, once at the start for `initial`. + public static Container segments(final String[] labels, int initial, final Chosen chosen) { + final Container row = new Container(new GridLayout(1, labels.length), "BnSegments"); + for (int iter = 0; iter < labels.length; iter++) { + final int index = iter; + Button one = new Button(Lang.tr(labels[iter]), "BnSegment"); + one.addActionListener(evt -> { + select(row, index); + chosen.chose(index); + }); + row.add(one); + } + select(row, initial); + chosen.chose(initial); + return row; + } + + public interface Chosen { + void chose(int index); + } + + private static void select(Container row, int index) { + for (int iter = 0; iter < row.getComponentCount(); iter++) { + row.getComponentAt(iter).setUIID(iter == index ? "BnSegmentOn" : "BnSegment"); + } + row.repaint(); + } + + /// A picker of the things named by `ids`, shown as `labels` (one per id), + /// starting at `initialId` (the first when it is null or absent). Read + /// the choice with [#pickedId]: it answers the id, never the label, so + /// two payees with one nickname are still two payees. + /// + /// A string picker only knows its strings, so labels that repeat are made + /// distinct with a counter (`Rent`, `Rent (2)`) before it sees them. + public static Picker picker(String[] ids, String[] labels, String initialId) { + String[] shown = distinct(labels); + Picker picker = new Picker(); + picker.setType(Display.PICKER_TYPE_STRINGS); + // setStrings localizes the array in place, so `shown` stays the + // picker's own strings and their positions stay those of `ids`. + picker.setStrings(shown); + picker.putClientProperty(IDS, ids.clone()); + if (shown.length > 0) { + picker.setSelectedStringIndex(Math.max(0, indexOf(ids, initialId))); + } + picker.setUIID("BnField"); + return picker; + } + + /// The id of what `picker` is on, or null when it is on nothing. + public static String pickedId(Picker picker) { + String[] ids = (String[]) picker.getClientProperty(IDS); + int index = picker.getSelectedStringIndex(); + return ids == null || index < 0 || index >= ids.length ? null : ids[index]; + } + + /// Where `id` is in `ids`, or -1. + public static int indexOf(String[] ids, String id) { + for (int iter = 0; id != null && iter < ids.length; iter++) { + if (id.equals(ids[iter])) { + return iter; + } + } + return -1; + } + + private static final String IDS = "BnPickerIds"; + + static String[] distinct(String[] labels) { + String[] out = new String[labels.length]; + for (int iter = 0; iter < labels.length; iter++) { + String label = labels[iter]; + int n = 1; + while (indexOf(out, label) >= 0) { + n++; + label = labels[iter] + " (" + n + ")"; + } + out[iter] = label; + } + return out; + } + + /// Asks a yes-or-no question about money that is about to move; `yes` + /// is the button that does it. + public static boolean confirm(String title, String body, String yes) { + return Dialog.show(Lang.tr(title), body, Lang.tr(yes), Lang.tr("Cancel")); + } + + /// A short message that goes away by itself. + public static void toast(String text) { + ToastBar.showInfoMessage(Lang.tr(text)); + } + + /// When something happened, the way the lists show it. + public static String when(long millis) { + if (millis <= 0) { + return ""; + } + return new SimpleDateFormat("MMM d, HH:mm").format(new Date(millis)); + } + + /// A day, the way a statement's period shows it. + public static String day(long millis) { + return new SimpleDateFormat("MMM d").format(new Date(millis)); + } + + /// Adds a quiet line of detail, wrapping, under a row's title. + public static void addDetail(Container to, String text) { + SpanLabel line = new SpanLabel(text); + line.setTextUIID("BnRowDetail"); + to.add(line); + } + + /// A rebuildable form: `content` is emptied and refilled on every load. + /// While the first answer is on its way it shows the shape of what is + /// coming -- a spinner over grey placeholder rows -- rather than a bare + /// line of text; a failure shows the reason and a way to try again. + public static void loading(Container content) { + content.removeAll(); + InfiniteProgress spinner = new InfiniteProgress(); + content.add(FlowLayout.encloseCenter(spinner)); + Label caption = new Label(Lang.tr("Loading..."), "BnLoadingCaption"); + content.add(FlowLayout.encloseCenter(caption)); + for (int iter = 0; iter < 3; iter++) { + Container card = new Container(BoxLayout.y(), "BnSkeletonCard"); + card.add(skeletonBar(iter == 1 ? 45 : 60, 3f)); + card.add(skeletonBar(iter == 2 ? 70 : 35, 2.2f)); + content.add(card); + } + content.revalidate(); + } + + /// A rounded grey bar `percent` of the page wide and `mm` tall. + private static Component skeletonBar(final int percent, final float mm) { + Component bar = new Component() { + @Override + protected com.codename1.ui.geom.Dimension calcPreferredSize() { + return new com.codename1.ui.geom.Dimension( + CN.getDisplayWidth() * percent / 100, CN.convertToPixels(mm)); + } + }; + bar.setUIID("BnSkeletonBar"); + return bar; + } + + /// "Enter an amount, like 25.00.", with the example written the way this + /// locale writes an amount (25,00 in much of Europe). + public static String amountHint() { + return Lang.tr("Enter an amount, like {0}.", Amounts.format(2500L, 2, 2)); + } + + public static void failed(final Container content, String message, final Runnable retry) { + content.removeAll(); + SpanLabel error = error(); + content.add(error); + showError(error, Lang.tr(message)); + Button again = secondary("Try again"); + again.addActionListener(evt -> retry.run()); + content.add(again); + content.revalidate(); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/UnlockSetup.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/UnlockSetup.java new file mode 100644 index 00000000000..19b5c152dc8 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/UnlockSetup.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codenameone.examples.bastion.security.Keys; + +/// Turning on the two ways to unlock, shared by onboarding and Settings. +public final class UnlockSetup { + private UnlockSetup() { + } + + /// Stores the biometric copy of the app key; on Android the OS asks for + /// the biometric to write it. + public static void biometric(final Runnable done, final SpanLabel error) { + Keys.enableBiometric(Lang.tr("Let your biometric unlock Bastion"), done, + why -> Ui.showError(error, Lang.tr(why))); + } + + /// A new PIN, typed twice. + public static final class PinFields { + public final TextField pin = Ui.field("New PIN", TextArea.NUMERIC | TextArea.PASSWORD); + public final TextField again = Ui.field("The same PIN again", + TextArea.NUMERIC | TextArea.PASSWORD); + + public PinFields() { + pin.setMaxSize(Keys.MAX_PIN); + again.setMaxSize(Keys.MAX_PIN); + } + + /// Checks the two and stores the PIN copy of the app key. + public void save(final Runnable done, final SpanLabel error) { + String typed = pin.getText(); + if (!typed.equals(again.getText())) { + Ui.showError(error, Lang.tr("The two PINs are not the same.")); + return; + } + if (!Keys.validPin(typed)) { + Ui.showError(error, Lang.tr("Choose 6 to 12 digits that are not a simple run.")); + return; + } + Keys.setPin(typed, () -> { + pin.setText(""); + again.setText(""); + done.run(); + }, why -> Ui.showError(error, Lang.tr(why))); + } + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/WithdrawForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/WithdrawForm.java new file mode 100644 index 00000000000..861b9e79b81 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/WithdrawForm.java @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Command; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.spinner.Picker; +import com.codenameone.examples.bastion.api.AccountDto; +import com.codenameone.examples.bastion.api.CryptoAddressDto; +import com.codenameone.examples.bastion.api.CryptoWithdrawalDto; +import com.codenameone.examples.bastion.api.NewCryptoAddressDto; +import com.codenameone.examples.bastion.api.ReceiptDto; +import com.codenameone.examples.bastion.money.Amounts; +import com.codenameone.examples.bastion.money.Assets; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import com.codenameone.examples.bastion.security.RequestSigner; +import java.util.ArrayList; +import java.util.List; + +/// Sending coins out of the bank, to an address on the user's own list. +/// +/// A crypto withdrawal cannot be called back, so it is fenced three ways: +/// +/// - **An allow-list.** Coins go only to an address the user added before; +/// there is no field to paste an address into on this screen. Adding one +/// is signed by the device. +/// - **A delay.** A newly added address becomes usable only after a waiting +/// period (`usableAt`), so an attacker who adds their own has to sit out +/// a window in which the owner is told and can remove it. +/// - **A signature and a key.** The withdrawal itself is signed and carries +/// an idempotency key, like every other money action, and counts toward +/// the account's crypto limit on the server. +public final class WithdrawForm { + private static final String ADDRESSES = "/api/crypto/addresses"; + + private WithdrawForm() { + } + + /// `walletId` preselects the wallet to send from, or null. + public static void show(final String walletId, final Runnable back) { + Form form = Nav.form("Send crypto", back); + final Container content = new Container(BoxLayout.y()); + form.add(content); + Ui.show(form, true); + load(content, walletId, back); + } + + private static void load(final Container content, final String walletId, final Runnable back) { + Ui.loading(content); + Api.banking().accounts(Net.to((List accounts) -> + Api.exchange().addresses(Net.to((List addresses) -> + fill(content, accounts, addresses, walletId, back), + (status, message) -> Ui.failed(content, message, + () -> load(content, walletId, back)))), + (status, message) -> Ui.failed(content, message, + () -> load(content, walletId, back)))); + } + + private static void fill(final Container content, List accounts, + final List addresses, String walletId, final Runnable back) { + content.removeAll(); + final Runnable again = () -> load(content, walletId, back); + final List wallets = new ArrayList(); + int initial = 0; + for (AccountDto account : accounts) { + if (Assets.isCrypto(account.asset()) && !account.frozen()) { + if (account.id().equals(walletId)) { + initial = wallets.size(); + } + wallets.add(account); + } + } + long now = System.currentTimeMillis(); + if (wallets.isEmpty()) { + content.add(Ui.empty("Open a crypto wallet to send coins from.")); + } else { + content.add(sendPanel(wallets, initial, addresses, now, back)); + } + + content.add(Ui.section("Your addresses")); + Button add = Ui.secondary("Add an address"); + add.addActionListener(evt -> addAddress(again)); + if (addresses.isEmpty()) { + content.add(Ui.empty("No addresses yet. Coins can be sent only to an address on " + + "this list.")); + } + for (final CryptoAddressDto address : addresses) { + boolean waiting = address.usableAt() > now; + String detail = address.asset() + " - " + address.address() + + (waiting ? "\n" + Lang.tr("Usable from {0}", Ui.when(address.usableAt())) : ""); + Container line = new Container(new com.codename1.ui.layouts.BorderLayout()); + line.add(com.codename1.ui.layouts.BorderLayout.CENTER, Ui.row(address.label(), detail, + Lang.tr(waiting ? "Waiting" : "Ready"), waiting ? "BnBadgeWarn" : "BnBadgeGood", + () -> remove(address, again))); + line.add(com.codename1.ui.layouts.BorderLayout.EAST, + Ui.copyButton(address.label(), address.address(), false)); + content.add(line); + } + content.add(add); + content.revalidate(); + } + + private static Container sendPanel(final List wallets, int initial, + final List addresses, final long now, final Runnable back) { + final Container panel = new Container(BoxLayout.y(), "BnPanel"); + final String[] walletLabels = TransferForm.labels(wallets); + final String[] walletIds = TransferForm.ids(wallets); + final Picker wallet = Ui.picker(walletIds, walletLabels, + walletIds[Math.min(initial, walletIds.length - 1)]); + final Container target = new Container(BoxLayout.y()); + final List usable = new ArrayList(); + final Picker[] to = new Picker[1]; + final String[][] toLabels = new String[1][]; + final String[][] toIds = new String[1][]; + final Runnable retarget = () -> { + AccountDto from = wallets.get(Math.max(0, Ui.indexOf(walletIds, Ui.pickedId(wallet)))); + usable.clear(); + for (CryptoAddressDto address : addresses) { + if (address.asset().equals(from.asset()) && address.usableAt() <= now) { + usable.add(address); + } + } + target.removeAll(); + toLabels[0] = new String[usable.size()]; + toIds[0] = new String[usable.size()]; + for (int iter = 0; iter < usable.size(); iter++) { + toIds[0][iter] = usable.get(iter).id(); + toLabels[0][iter] = usable.get(iter).label() + " - " + shorten(usable.get(iter).address()); + } + if (usable.isEmpty()) { + to[0] = null; + target.add(Ui.empty(Lang.tr("No {0} address is ready yet.", from.asset()))); + } else { + to[0] = Ui.picker(toIds[0], toLabels[0], null); + target.add(to[0]); + } + target.revalidate(); + }; + wallet.addActionListener(evt -> retarget.run()); + retarget.run(); + final TextField amount = Ui.field("Amount", TextField.DECIMAL); + final SpanLabel error = Ui.error(); + final Button send = Ui.primary("Review"); + panel.addAll(Ui.section("From"), wallet, Ui.section("To"), target, amount, error, send); + final CryptoWithdrawalDto[] tried = new CryptoWithdrawalDto[1]; + final String[] key = new String[1]; + send.addActionListener(evt -> { + Ui.showError(error, null); + final AccountDto from = wallets.get(Math.max(0, Ui.indexOf(walletIds, Ui.pickedId(wallet)))); + if (to[0] == null) { + Ui.showError(error, Lang.tr("Choose an address.")); + return; + } + CryptoAddressDto address = usable.get(Math.max(0, Ui.indexOf(toIds[0], Ui.pickedId(to[0])))); + long units = Amounts.parse(amount.getText(), from.asset()); + if (units <= 0 || units > from.balance()) { + Ui.showError(error, Lang.tr(units <= 0 ? "Enter an amount." + : "That is more than this wallet holds.")); + return; + } + final CryptoWithdrawalDto withdrawal = new CryptoWithdrawalDto(address.id(), units); + if (!Ui.confirm("Confirm", Lang.tr("Send {0} to {1}? This cannot be undone.", + Amounts.format(units, from.asset()), address.address()), "Send")) { + return; + } + if (tried[0] == null || !tried[0].equals(withdrawal)) { + tried[0] = withdrawal; + key[0] = RequestSigner.idempotencyKey(); + } + send.setEnabled(false); + final String target2 = "/api/accounts/" + from.id() + "/crypto-withdraw"; + DeviceSetup.withDevice(failed -> Api.exchange().withdrawCrypto(from.id(), key[0], + RequestSigner.sign("POST", target2, key[0], withdrawal), withdrawal, + Net.to((ReceiptDto receipt) -> ReceiptForm.show(receipt, address.address(), back), failed)), + (status, message) -> { + send.setEnabled(true); + Ui.showError(error, Lang.tr(message)); + }); + }); + return panel; + } + + private static void addAddress(final Runnable done) { + List coins = new ArrayList(); + for (Assets.Asset asset : Assets.all()) { + if (asset.isCrypto()) { + coins.add(asset.code); + } + } + final String[] codes = coins.toArray(new String[coins.size()]); + Picker coin = Ui.picker(codes, codes, null); + TextField address = Ui.field("Address", TextField.ANY); + TextField label = Ui.field("Label, like My hardware wallet", TextField.ANY); + Container body = new Container(BoxLayout.y()); + body.addAll(coin, address, label); + Command ok = new Command(Lang.tr("Add")); + if (Dialog.show(Lang.tr("Add an address"), body, ok, new Command(Lang.tr("Cancel"))) != ok) { + return; + } + final NewCryptoAddressDto added = new NewCryptoAddressDto(Ui.pickedId(coin), + address.getText().trim(), label.getText().trim()); + DeviceSetup.withDevice(failed -> Api.exchange().addAddress( + RequestSigner.sign("POST", ADDRESSES, null, added), added, + Net.to((CryptoAddressDto saved) -> { + Dialog.show(Lang.tr("Address added"), Lang.tr("For your safety it can be used " + + "from {0}.", Ui.when(saved.usableAt())), Lang.tr("OK"), null); + done.run(); + }, failed)), + (status, message) -> Dialog.show(Lang.tr("Not added"), Lang.tr(message), + Lang.tr("OK"), null)); + } + + private static void remove(final CryptoAddressDto address, final Runnable done) { + if (!Ui.confirm("Remove address", Lang.tr("Remove {0}? Adding it again starts a new wait.", + address.label()), "Remove")) { + return; + } + // Signed, as adding one is: the list is what a withdrawal is checked + // against. + DeviceSetup.withDevice(retry -> Api.exchange().removeAddress(address.id(), + RequestSigner.sign("DELETE", "/api/crypto/addresses/" + address.id(), null, null), + Net.to((CryptoAddressDto gone) -> done.run(), retry)), + (status, message) -> Dialog.show(Lang.tr("Not removed"), Lang.tr(message), + Lang.tr("OK"), null)); + } + + /// The start and end of a long address: enough to compare with the + /// wallet's, short enough for a row. + static String shorten(String address) { + if (address == null || address.length() <= 16) { + return address; + } + return address.substring(0, 8) + "..." + address.substring(address.length() - 6); + } +} diff --git a/scripts/bastion/common/src/main/l10n/Bundle.properties b/scripts/bastion/common/src/main/l10n/Bundle.properties new file mode 100644 index 00000000000..c07a695976e --- /dev/null +++ b/scripts/bastion/common/src/main/l10n/Bundle.properties @@ -0,0 +1,3 @@ +# Workaround for the simulator AutoLocalizationBundle @im fabrication crash +# in Codename One <= 7.0.236. Once the framework fix ships, this file can be removed. +@im= diff --git a/scripts/bastion/common/src/main/l10n/Bundle_de.properties b/scripts/bastion/common/src/main/l10n/Bundle_de.properties new file mode 100644 index 00000000000..24bd933678a --- /dev/null +++ b/scripts/bastion/common/src/main/l10n/Bundle_de.properties @@ -0,0 +1,745 @@ +# Bastion in German. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +# These translations were generated automatically as a sample and have not +# been reviewed by a native speaker. Review them before shipping. +@rtl=false +language.name=Deutsch + +Account=Konto +Frozen=Eingefroren +Move\ money=Geld verschieben +Send=Senden +Add\ money=Geld einzahlen +Withdraw=Abheben +Order\ a\ card=Karte bestellen +No\ card=Keine Karte +History=Verlauf +Nothing\ has\ moved\ in\ this\ account\ yet.=Auf diesem Konto gab es noch keine Bewegungen. +Money\ in=Geldeingang +Money\ out=Geldausgang +Amount=Betrag +Add=Hinzuf\u00fcgen +Withdraw\ cash=Bargeld abheben +Cancel=Abbrechen +Not\ done=Nicht ausgef\u00fchrt +Administration=Verwaltung +Administrators\ only.=Nur f\u00fcr Administratoren. +Recomputed\ from\ the\ stored\ rows\ each\ time\ you\ ask.=Wird bei jeder Anfrage aus den gespeicherten Zeilen neu berechnet. +Verify\ the\ audit\ log=Audit-Protokoll pr\u00fcfen +Verify\ the\ ledger=Hauptbuch pr\u00fcfen +Audit\ log=Audit-Protokoll +Ledger=Hauptbuch +Intact=Unversehrt +Problems\ found=Probleme gefunden +Cards=Karten +No\ cards\ yet.\ Order\ one\ from\ a\ checking\ account.=Noch keine Karten. Bestelle eine \u00fcber ein Girokonto. +Bastion=Bastion +Spends\ up\ to\ {0}\ a\ day=Ausgaben bis zu {0} pro Tag +Unfreeze=Entsperren +Freeze=Einfrieren +Not\ changed=Nicht ge\u00e4ndert +Daily\ limit=Tageslimit +Save=Speichern +Sign\ in\ first=Zuerst anmelden +Password=Passwort +Continue=Weiter +Confirm\ it\ is\ you=Best\u00e4tige, dass du es bist +Sign\ with\ this\ device=Mit diesem Ger\u00e4t signieren +Payments\ leaving\ your\ accounts\ are\ signed\ by\ a\ key\ that\ never\ leaves\ this\ phone.\ Register\ this\ device\ to\ continue.=Zahlungen von deinen Konten werden mit einem Schl\u00fcssel signiert, der dieses Telefon nie verl\u00e4sst. Registriere dieses Ger\u00e4t, um fortzufahren. +Register=Registrieren +Not\ now=Nicht jetzt +Exchange=B\u00f6rse +Markets=M\u00e4rkte +No\ markets\ are\ open.=Keine M\u00e4rkte ge\u00f6ffnet. +Index\ {0}=Index {0} +Your\ trades=Deine Trades +You\ have\ not\ traded\ yet.=Du hast noch nicht gehandelt. +Welcome=Willkommen +Hello,\ {0}=Hallo, {0} +Total\ balance=Gesamtguthaben +Not\ counted,\ no\ dollar\ price\:\ {0}=Nicht mitgez\u00e4hlt, kein Dollarpreis: {0} +Pay\ someone=Jemanden bezahlen +Trade=Handeln +Accounts=Konten +You\ have\ no\ accounts\ yet.=Du hast noch keine Konten. +Open\ an\ account=Konto er\u00f6ffnen +Checking=Girokonto +Savings=Sparkonto +Wallet=Wallet +This\ device\ cannot\ sign\ payments\ yet=Dieses Ger\u00e4t kann noch keine Zahlungen signieren +Payments\ to\ other\ people\ and\ crypto\ withdrawals\ need\ a\ key\ that\ lives\ only\ on\ this\ phone.=Zahlungen an andere Personen und Krypto-Auszahlungen erfordern einen Schl\u00fcssel, der nur auf diesem Telefon liegt. +Register\ this\ device=Dieses Ger\u00e4t registrieren +{0}\ wallet={0}-Wallet +{0}\ checking={0}-Girokonto +{0}\ savings={0}-Sparkonto +Name\ (optional)=Name (optional) +Open=Er\u00f6ffnen +Not\ opened=Nicht er\u00f6ffnet +Locked=Gesperrt +Bastion\ is\ locked=Bastion ist gesperrt +Unlock=Entsperren +Sign\ in\ again=Erneut anmelden +Not\ recognised.\ Try\ again.=Nicht erkannt. Versuche es erneut. +Last\ price=Letzter Kurs +Order\ book=Orderbuch +Place\ an\ order=Order aufgeben +Your\ open\ orders=Deine offenen Orders +Bid\ {0}\ -\ Ask\ {1}=Geld {0} - Brief {1} +The\ book\ is\ empty.=Das Orderbuch ist leer. +Buy=Kaufen +Sell=Verkaufen +at\ {0},\ {1}\ filled=zu {0}, {1} ausgef\u00fchrt +No\ open\ orders\ in\ this\ market.=Keine offenen Orders in diesem Markt. +Price\ in\ {0}=Preis in {0} +Amount\ of\ {0}=Menge {0} +Spend,\ in\ {0}=Ausgabe, in {0} +In\ steps\ of\ {0}=In Schritten von {0} +Place\ order=Order aufgeben +Confirm\ order=Order best\u00e4tigen +Place=Aufgeben +Order\ {0}=Order {0} +Enter\ how\ much\ to\ spend.=Gib ein, wie viel ausgegeben werden soll. +Enter\ an\ amount\ in\ steps\ of\ {0}.=Gib einen Betrag in Schritten von {0} ein. +Enter\ a\ price.=Gib einen Preis ein. +Buy\ {0}\ at\ the\ best\ prices,\ spending\ up\ to\ {1}?={0} zu den besten Preisen kaufen und bis zu {1} ausgeben? +Sell\ {0}\ at\ the\ best\ prices?={0} zu den besten Preisen verkaufen? +Buy\ {0}\ at\ {1}\ (about\ {2})?={0} zu {1} kaufen (ca. {2})? +Sell\ {0}\ at\ {1}\ (about\ {2})?={0} zu {1} verkaufen (ca. {2})? +filled=ausgef\u00fchrt +on\ the\ book=im Orderbuch +ended,\ the\ rest\ unfilled=beendet, der Rest nicht ausgef\u00fchrt +Bought\ {0}={0} gekauft +Sold\ {0}={0} verkauft +at\ {0},\ fee\ {1}=zu {0}, Geb\u00fchr {1} +Payees=Empf\u00e4nger +Send\ crypto=Krypto senden +Security\ Center=Sicherheitscenter +Settings=Einstellungen +Sign\ out=Abmelden +Bank\ with\ Bastion=Banking mit Bastion +Takes\ two\ minutes.\ You\ will\ set\ up\ a\ second\ factor\ and\ this\ phone's\ signing\ key\ as\ you\ go.=Dauert zwei Minuten. Dabei richtest du einen zweiten Faktor und den Signaturschl\u00fcssel dieses Telefons ein. +Your\ name=Dein Name +E-mail=E-Mail +Password\ again=Passwort wiederholen +Create\ account=Konto erstellen +At\ least\ {0}\ characters=Mindestens {0} Zeichen +Both\ passwords\ match=Beide Passw\u00f6rter stimmen \u00fcberein +Not\ your\ e-mail\ address=Nicht deine E-Mail-Adresse +A\ few\ unrelated\ words\ make\ a\ password\ that\ is\ long\ and\ easy\ to\ remember.=Ein paar unzusammenh\u00e4ngende W\u00f6rter ergeben ein langes, leicht zu merkendes Passwort. +Sign\ in\ to\ continue.=Melde dich an, um fortzufahren. +Authenticator\ app=Authenticator-App +Add\ a\ second\ factor=Zweiten Faktor hinzuf\u00fcgen +Scan\ this\ with\ an\ authenticator\ app.\ From\ now\ on\ a\ sign-in\ with\ your\ password\ also\ asks\ for\ the\ code\ it\ shows.=Scanne dies mit einer Authenticator-App. Ab jetzt verlangt die Anmeldung mit deinem Passwort auch den dort angezeigten Code. +Or\ enter\ this\ key=Oder gib diesen Schl\u00fcssel ein +Copy\ key=Schl\u00fcssel kopieren +Copy\ codes=Codes kopieren +Copied.\ The\ clipboard\ clears\ itself\ in\ a\ minute.=Kopiert. Die Zwischenablage leert sich in einer Minute von selbst. +Open\ authenticator=Authenticator \u00f6ffnen +Six-digit\ code=Sechsstelliger Code +Turn\ on=Einschalten +New\ recovery\ codes=Neue Wiederherstellungscodes +Recovery\ codes=Wiederherstellungscodes +Save\ your\ recovery\ codes=Speichere deine Wiederherstellungscodes +Each\ signs\ you\ in\ once\ if\ you\ lose\ your\ phone.\ Keep\ them\ somewhere\ safe\ and\ offline.\ They\ will\ not\ be\ shown\ again.=Jeder meldet dich einmal an, falls du dein Telefon verlierst. Bewahre sie sicher und offline auf. Sie werden nicht noch einmal angezeigt. +I\ have\ saved\ them=Ich habe sie gespeichert +Passkey=Passkey +Sign\ in\ with\ a\ passkey=Mit einem Passkey anmelden +A\ passkey\ is\ kept\ by\ your\ phone\ and\ unlocked\ by\ your\ face,\ fingerprint\ or\ screen\ lock.\ It\ cannot\ be\ phished,\ and\ replaces\ both\ the\ password\ and\ the\ code.=Ein Passkey wird von deinem Telefon aufbewahrt und mit Gesicht, Fingerabdruck oder Displaysperre entsperrt. Er l\u00e4sst sich nicht phishen und ersetzt sowohl das Passwort als auch den Code. +Create\ a\ passkey=Passkey erstellen +Bastion\ on\ {0}=Bastion auf {0} +Unlock\ with\ your\ face\ or\ fingerprint?=Mit Gesicht oder Fingerabdruck entsperren? +Bastion\ locks\ itself\ when\ you\ leave\ it.\ With\ this\ on,\ a\ biometric\ unlocks\ it;\ with\ it\ off,\ you\ sign\ in\ again.\ The\ device\ passcode\ is\ never\ accepted.=Bastion sperrt sich selbst, wenn du die App verl\u00e4sst. Ist dies eingeschaltet, entsperrt ein biometrisches Merkmal sie; ist es ausgeschaltet, meldest du dich erneut an. Der Ger\u00e4tecode wird nie akzeptiert. +Use\ biometrics=Biometrie verwenden +This\ device=Dieses Ger\u00e4t +Let\ this\ phone\ sign\ your\ payments=Dieses Telefon deine Zahlungen signieren lassen +Bastion\ makes\ a\ key\ on\ this\ phone\ and\ gives\ the\ bank\ only\ its\ public\ half.\ Payments\ to\ other\ people\ and\ crypto\ withdrawals\ must\ carry\ its\ signature,\ so\ a\ stolen\ password\ alone\ cannot\ move\ your\ money.=Bastion erzeugt auf diesem Telefon einen Schl\u00fcssel und gibt der Bank nur dessen \u00f6ffentlichen Teil. Zahlungen an andere Personen und Krypto-Auszahlungen m\u00fcssen seine Signatur tragen, sodass ein gestohlenes Passwort allein dein Geld nicht bewegen kann. +Later=Sp\u00e4ter +Add\ a\ payee=Empf\u00e4nger hinzuf\u00fcgen +No\ payees\ yet.\ Add\ one\ by\ the\ e-mail\ address\ they\ bank\ with.=Noch keine Empf\u00e4nger. F\u00fcge einen \u00fcber die E-Mail-Adresse hinzu, mit der er bei der Bank ist. +New\ payee\:\ limited\ until\ {0}=Neuer Empf\u00e4nger: eingeschr\u00e4nkt bis {0} +Cooling\ off=Abk\u00fchlphase +Ready=Bereit +Their\ e-mail=E-Mail des Empf\u00e4ngers +Name\ to\ show=Angezeigter Name +Payee\ added.\ It\ is\ limited\ while\ it\ cools\ off.=Empf\u00e4nger hinzugef\u00fcgt. Er ist w\u00e4hrend der Abk\u00fchlphase eingeschr\u00e4nkt. +Not\ added=Nicht hinzugef\u00fcgt +Rename=Umbenennen +Remove=Entfernen +Remove\ payee=Empf\u00e4nger entfernen +Remove\ {0}?\ Adding\ them\ again\ starts\ a\ new\ cooling-off.={0} entfernen? Beim erneuten Hinzuf\u00fcgen beginnt eine neue Abk\u00fchlphase. +Done=Fertig +Sent=Gesendet +Reference=Referenz +When=Zeitpunkt +Note=Notiz +What\ protects\ your\ account\ on\ this\ device.\ Nothing\ here\ is\ decoration\:\ each\ line\ is\ read\ from\ the\ app\ as\ it\ runs.=Was dein Konto auf diesem Ger\u00e4t sch\u00fctzt. Nichts hier ist Dekoration: Jede Zeile wird zur Laufzeit aus der App gelesen. +Back=Zur\u00fcck +Unlock\ with\ fingerprint\ or\ face=Mit Fingerabdruck oder Gesicht entsperren +This\ device\ has\ no\ biometric\ enrolled.\ The\ lock\ asks\ for\ your\ password.=Auf diesem Ger\u00e4t ist keine Biometrie eingerichtet. Die Sperre fragt nach deinem Passwort. +Off=Aus +Sign-in=Anmeldung +{0}\ recovery\ codes\ left=Noch {0} Wiederherstellungscodes +Adds\ a\ six-digit\ code\ to\ every\ password\ sign-in.=F\u00fcgt jeder Anmeldung mit Passwort einen sechsstelligen Code hinzu. +On=Ein +Payment\ signing\ key=Zahlungssignaturschl\u00fcssel +Payments\ from\ this\ phone\ are\ signed\ by\ a\ key\ that\ never\ leaves\ it.=Zahlungen von diesem Telefon werden mit einem Schl\u00fcssel signiert, der es nie verl\u00e4sst. +Register\ this\ device\ to\ sign\ payments.=Registriere dieses Ger\u00e4t, um Zahlungen zu signieren. +Sign\ in=Anmelden +Your\ bank,\ locked\ down.=Deine Bank, bestens gesichert. +Verification=Verifizierung +Enter\ your\ code=Gib deinen Code ein +Open\ your\ authenticator\ app\ and\ enter\ the\ six-digit\ code,\ or\ one\ of\ your\ recovery\ codes.=\u00d6ffne deine Authenticator-App und gib den sechsstelligen Code oder einen deiner Wiederherstellungscodes ein. +Code=Code +You\ have\ no\ account\ that\ can\ send\ money.=Du hast kein Konto, von dem Geld gesendet werden kann. +Add\ a\ payee\ first,\ under\ Payees.=F\u00fcge zuerst unter Empf\u00e4nger einen Empf\u00e4nger hinzu. +Note\ (optional)=Notiz (optional) +Review=Pr\u00fcfen +From=Von +To=An +Choose\ who\ to\ pay.=W\u00e4hle, wen du bezahlen m\u00f6chtest. +That\ is\ more\ than\ this\ account\ holds.=Das ist mehr, als auf diesem Konto liegt. +Choose\ two\ different\ accounts.=W\u00e4hle zwei verschiedene Konten. +Confirm=Best\u00e4tigen +Send\ {0}\ from\ {1}\ to\ {2}?={0} von {1} an {2} senden? +Loading...=Wird geladen ... +Open\ a\ crypto\ wallet\ to\ send\ coins\ from.=Er\u00f6ffne ein Krypto-Wallet, von dem Coins gesendet werden. +Your\ addresses=Deine Adressen +Add\ an\ address=Adresse hinzuf\u00fcgen +No\ addresses\ yet.\ Coins\ can\ be\ sent\ only\ to\ an\ address\ on\ this\ list.=Noch keine Adressen. Coins k\u00f6nnen nur an eine Adresse dieser Liste gesendet werden. +Usable\ from\ {0}=Nutzbar ab {0} +Waiting=Wartet +No\ {0}\ address\ is\ ready\ yet.=Noch keine {0}-Adresse ist bereit. +Choose\ an\ address.=W\u00e4hle eine Adresse. +Enter\ an\ amount.=Gib einen Betrag ein. +That\ is\ more\ than\ this\ wallet\ holds.=Das ist mehr, als in diesem Wallet liegt. +Send\ {0}\ to\ {1}?\ This\ cannot\ be\ undone.={0} an {1} senden? Das l\u00e4sst sich nicht r\u00fcckg\u00e4ngig machen. +Address=Adresse +Label,\ like\ My\ hardware\ wallet=Bezeichnung, z. B. Mein Hardware-Wallet +Address\ added=Adresse hinzugef\u00fcgt +For\ your\ safety\ it\ can\ be\ used\ from\ {0}.=Zu deiner Sicherheit kann sie ab {0} verwendet werden. +Remove\ address=Adresse entfernen +Remove\ {0}?\ Adding\ it\ again\ starts\ a\ new\ wait.={0} entfernen? Beim erneuten Hinzuf\u00fcgen beginnt eine neue Wartezeit. +Not\ removed=Nicht entfernt +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=Der Server ist nicht erreichbar. Pr\u00fcfe deine Verbindung. +Something\ went\ wrong\ ({0}).\ Try\ again.=Etwas ist schiefgelaufen ({0}). Versuche es erneut. +The\ server\ changed.\ Sign\ in\ again.=Der Server hat sich ge\u00e4ndert. Melde dich erneut an. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=Zu viele Versuche. Warte eine Minute und versuche es erneut. +This\ account\ is\ locked\ for\ a\ while\ after\ too\ many\ attempts.=Dieses Konto ist nach zu vielen Versuchen eine Weile gesperrt. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=Diese E-Mail und dieses Passwort passen zu keinem Konto. +That\ code\ is\ not\ right.\ Codes\ change\ every\ 30\ seconds.=Dieser Code ist nicht richtig. Codes \u00e4ndern sich alle 30 Sekunden. +Passkey\ sign-in\ is\ not\ available\ right\ now.=Die Anmeldung mit Passkey ist derzeit nicht verf\u00fcgbar. +That\ passkey\ was\ not\ accepted.=Dieser Passkey wurde nicht akzeptiert. +Passkey\ sign-in\ was\ cancelled.=Die Anmeldung mit Passkey wurde abgebrochen. +Signing\ in\ failed.\ Try\ again.=Die Anmeldung ist fehlgeschlagen. Versuche es erneut. +Sign\ in\ again\ to\ add\ a\ passkey.=Melde dich erneut an, um einen Passkey hinzuzuf\u00fcgen. +Passkeys\ are\ not\ available\ right\ now.=Passkeys sind derzeit nicht verf\u00fcgbar. +That\ passkey\ was\ not\ saved.=Dieser Passkey wurde nicht gespeichert. +Creating\ the\ passkey\ was\ cancelled.=Das Erstellen des Passkeys wurde abgebrochen. +This\ device\ has\ no\ secure\ storage\ for\ the\ key.=Dieses Ger\u00e4t hat keinen sicheren Speicher f\u00fcr den Schl\u00fcssel. +Unlock\ Bastion=Bastion entsperren +Unlock\ Bastion\ and\ the\ keys\ that\ sign\ your\ payments=Bastion und die Schl\u00fcssel zum Signieren deiner Zahlungen entsperren +Network=Netzwerk +Keys\ and\ sign-in=Schl\u00fcssel und Anmeldung +Build\ hardening=Build-H\u00e4rtung +No\ cleartext=Kein Klartext +Development\ server\ =Entwicklungsserver +\ is\ exempt\:\ plain\ HTTP\ to\ this\ machine\ only=\ ist ausgenommen: reines HTTP nur zu diesem Rechner +Every\ request\ is\ https;\ http\ is\ refused=Jede Anfrage verwendet https; http wird abgelehnt +Host\ allow-list=Host-Zulassungsliste +Exempt\ while\ the\ development\ server\ is\ in\ use=Ausgenommen, solange der Entwicklungsserver verwendet wird +Only\ =Nur +Over-the-air\ pins=Over-the-Air-Pins +Published\ by\ the\ attestation\ service;\ none\ in\ this\ build=Vom Attestierungsdienst ver\u00f6ffentlicht; in diesem Build keine +\ hosts,\ version\ =\ Hosts, Version +\ (refresh\ due)=\ (Aktualisierung f\u00e4llig) +App\ attestation\ token=App-Attestierungs-Token +AppShield\ engine\ =AppShield-Engine +AppShield\ needs\ the\ enterprise\ engine\ in\ a\ cloud\ build;\ inert\ here=AppShield ben\u00f6tigt in einem Cloud-Build die Enterprise-Engine; hier inaktiv +Device\ signing\ key=Ger\u00e4tesignaturschl\u00fcssel +P-256,\ registered\ as\ =P-256, registriert als +;\ signs\ every\ payment=; signiert jede Zahlung +Not\ registered\:\ payments\ that\ need\ a\ signature\ are\ refused=Nicht registriert: Zahlungen, die eine Signatur ben\u00f6tigen, werden abgelehnt +Idempotency\ keys=Idempotency-Keys +Each\ money\ action\ carries\ one,\ so\ a\ retry\ never\ pays\ twice=Jede Geldaktion tr\u00e4gt einen, sodass ein erneuter Versuch nie doppelt zahlt +Second\ factor\ and\ passkeys=Zweiter Faktor und Passkeys +Authenticator\ codes\ and\ passkeys=Authenticator-Codes und Passkeys +Authenticator\ codes\ (no\ passkeys\ on\ this\ platform)=Authenticator-Codes (keine Passkeys auf dieser Plattform) +Biometric\ lock=Biometrische Sperre +On\ start,\ on\ return\ and\ when\ idle=Beim Start, bei R\u00fcckkehr und im Leerlauf +No\ biometrics\ enrolled\:\ the\ lock\ asks\ for\ the\ password\ instead=Keine Biometrie eingerichtet: Die Sperre fragt stattdessen nach dem Passwort +Auto-lock=Automatische Sperre +After\ =Nach +s\ idle\ or\ =s im Leerlauf oder +s\ in\ the\ background=s im Hintergrund +Platform\ attestation=Plattform-Attestierung +Play\ Integrity=Play Integrity +App\ Attest=App Attest +Play\ Integrity\ /\ App\ Attest\ need\ a\ device\ build=Play Integrity / App Attest ben\u00f6tigen einen Ger\u00e4te-Build +Accessibility\ services=Bedienungshilfen +Can\ read\ the\ screen\:\ =K\u00f6nnen den Bildschirm lesen: +None\ that\ can\ read\ the\ screen=Keine, die den Bildschirm lesen k\u00f6nnen +Tapjacking=Tapjacking +Touches\ through\ overlays\ are\ dropped=Ber\u00fchrungen durch Overlays werden verworfen +Policy\ set;\ only\ Android\ has\ overlays=Richtlinie gesetzt; nur Android kennt Overlays +Screenshot\ blocking=Screenshot-Sperre +Set\ on\ sensitive\ screens;\ the\ simulator\ cannot\ block\ captures=Auf sensiblen Bildschirmen gesetzt; der Simulator kann Aufnahmen nicht blockieren +Sensitive\ screens,\ and\ the\ app\ switcher\ card=Sensible Bildschirme und die Karte im App-Umschalter +Sensitive\ screens\ and\ the\ recent-apps\ card=Sensible Bildschirme und die Karte in den zuletzt verwendeten Apps +Clipboard\ clearing=Zwischenablage leeren +Copied\ numbers\ are\ wiped\ after\ =Kopierte Nummern werden gel\u00f6scht nach +Code\ hardening=Code-H\u00e4rtung +Level\ =Stufe +,\ mapping\ =, Zuordnung +Declared\ =Deklariert +;\ applied\ by\ the\ build\ server=; vom Build-Server angewendet +Jailbreak\ exit\ (iOS)=Jailbreak-Beendigung (iOS) +App\ switcher\ blanking\ (iOS)=Ausblenden im App-Umschalter (iOS) +Play\ Integrity\ (Android)=Play Integrity (Android) +Certificate\ pinning=Zertifikats-Pinning +Not\ applied\ to\ the\ development\ server\ (it\ has\ no\ certificate)=Nicht auf den Entwicklungsserver angewendet (er hat kein Zertifikat) +Placeholder\ pins\:\ put\ the\ server's\ SPKI\ digests\ in\ AppConfig.PINS=Platzhalter-Pins: Trage die SPKI-Digests des Servers in AppConfig.PINS ein +\ hosts\ pinned=\ Hosts gepinnt +\ mismatches\ refused=\ Abweichungen abgelehnt +Token\ and\ key\ storage=Speicherung von Token und Schl\u00fcsseln +Hardware-backed\ keystore=Hardwaregest\u00fctzter Keystore +OS\ keystore=Betriebssystem-Keystore +The\ simulator\ keeps\ them\ in\ a\ plain\ file=Der Simulator speichert sie in einer einfachen Datei +No\ keystore\ on\ this\ device\:\ tokens\ are\ not\ kept\ between\ runs=Kein Keystore auf diesem Ger\u00e4t: Token bleiben zwischen Starts nicht erhalten +Root\ and\ jailbreak\ checks=Root- und Jailbreak-Pr\u00fcfungen +Run\ on\ a\ phone;\ the\ simulator\ reports\ nothing=Auf einem Telefon ausf\u00fchren; der Simulator meldet nichts +This\ device\ looks\ modified\:\ =Dieses Ger\u00e4t wirkt manipuliert: +No\ signs\ of\ root\ or\ jailbreak=Keine Anzeichen f\u00fcr Root oder Jailbreak +OK=OK +Last\ used\ {0}=Zuletzt verwendet {0} +Add\ a\ passkey=Passkey hinzuf\u00fcgen +This\ device\ cannot\ create\ passkeys.=Dieses Ger\u00e4t kann keine Passkeys erstellen. +Remove\ passkey=Passkey entfernen +Remove\ {0}?\ It\ will\ no\ longer\ sign\ in\ to\ this\ account.={0} entfernen? Damit ist keine Anmeldung bei diesem Konto mehr m\u00f6glich. +Code\ from\ your\ app=Code aus deiner App +Authenticator\ code=Authenticator-Code +Please\ sign\ in\ again.=Bitte melde dich erneut an. +Your\ account\ is\ not\ allowed\ to\ do\ that.=Dein Konto darf das nicht tun. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=Zu viele Versuche. Warte einen Moment und versuche es erneut. +Unlock\ Bastion\ first.=Entsperre zuerst Bastion. +PIN=PIN +Unlock\ with\ PIN=Mit PIN entsperren +Not\ recognised.\ Try\ again,\ or\ use\ your\ PIN.=Nicht erkannt. Versuche es erneut oder verwende deine PIN. +Enter\ your\ PIN\ of\ at\ least\ {0}\ digits.=Gib deine PIN mit mindestens {0} Ziffern ein. +Wrong\ PIN.\ One\ try\ left\ before\ the\ keys\ on\ this\ device\ are\ erased.=Falsche PIN. Noch ein Versuch, bevor die Schl\u00fcssel auf diesem Ger\u00e4t gel\u00f6scht werden. +Wrong\ PIN.\ {0}\ tries\ left.=Falsche PIN. Noch {0} Versuche. +Too\ many\ wrong\ PINs.\ The\ keys\ on\ this\ device\ were\ erased.=Zu viele falsche PINs. Die Schl\u00fcssel auf diesem Ger\u00e4t wurden gel\u00f6scht. +Unlock\ PIN=Entsperr-PIN +Choose\ an\ unlock\ PIN=Entsperr-PIN w\u00e4hlen +Set\ PIN=PIN festlegen +Change\ PIN=PIN \u00e4ndern +This\ device\ has\ no\ biometric\ enrolled.\ Set\ an\ unlock\ PIN\ instead.=Auf diesem Ger\u00e4t ist keine Biometrie eingerichtet. Lege stattdessen eine Entsperr-PIN fest. +Without\ a\ biometric\ or\ a\ PIN\ the\ lock\ asks\ for\ your\ password.=Ohne Biometrie oder PIN fragt die Sperre nach deinem Passwort. +Remove\ PIN=PIN entfernen +Let\ your\ biometric\ unlock\ Bastion=Bastion per Biometrie entsperren lassen +New\ PIN=Neue PIN +The\ same\ PIN\ again=Dieselbe PIN noch einmal +The\ two\ PINs\ are\ not\ the\ same.=Die beiden PINs stimmen nicht \u00fcberein. +Choose\ 6\ to\ 12\ digits\ that\ are\ not\ a\ simple\ run.=W\u00e4hle 6 bis 12 Ziffern, die keine einfache Folge sind. +No\ biometric\ or\ PIN\ is\ set\ up\ on\ this\ device,\ so\ the\ way\ back\ in\ is\ your\ password.=Auf diesem Ger\u00e4t sind weder Biometrie noch PIN eingerichtet, daher f\u00fchrt der Weg zur\u00fcck \u00fcber dein Passwort. +Signing\ in\ again\ sets\ this\ device\ up\ from\ the\ start\:\ its\ payment\ key,\ biometric\ and\ PIN\ are\ removed.=Bei erneuter Anmeldung wird dieses Ger\u00e4t von Grund auf neu eingerichtet: Zahlungsschl\u00fcssel, Biometrie und PIN werden entfernt. +Bastion\ locks\ itself\ when\ you\ leave\ it.\ With\ this\ on,\ the\ phone\ keeps\ the\ key\ to\ your\ session\ and\ payments\ where\ only\ your\ biometric\ releases\ it.\ The\ device\ passcode\ is\ never\ accepted.=Bastion sperrt sich selbst, wenn du die App verl\u00e4sst. Ist dies eingeschaltet, verwahrt das Telefon den Schl\u00fcssel f\u00fcr deine Sitzung und Zahlungen so, dass nur deine Biometrie ihn freigibt. Der Ger\u00e4tecode wird nie akzeptiert. +For\ when\ a\ biometric\ is\ not\ available\ or\ you\ would\ rather\ not\ use\ one\:\ {0}\ to\ {1}\ digits,\ not\ your\ card\ PIN.\ {2}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys\ on\ this\ device.=F\u00fcr den Fall, dass keine Biometrie verf\u00fcgbar ist oder du keine verwenden m\u00f6chtest: {0} bis {1} Ziffern, nicht die PIN deiner Karte. {2} falsche Versuche hintereinander l\u00f6schen die Schl\u00fcssel auf diesem Ger\u00e4t. +Opens\ the\ lock\ when\ a\ biometric\ cannot.\ {0}\ wrong\ tries\ erase\ the\ keys\ on\ this\ device.=\u00d6ffnet die Sperre, wenn eine Biometrie es nicht kann. {0} falsche Versuche l\u00f6schen die Schl\u00fcssel auf diesem Ger\u00e4t. +Unlock\ first.=Zuerst entsperren. +This\ device\ could\ not\ keep\ a\ biometric-bound\ key.=Dieses Ger\u00e4t konnte keinen biometrisch gebundenen Schl\u00fcssel speichern. +This\ device\ has\ no\ biometric-bound\ key\ store.=Dieses Ger\u00e4t hat keinen Speicher f\u00fcr biometrisch gebundene Schl\u00fcssel. +Biometric\ setup\ was\ cancelled.=Die biometrische Einrichtung wurde abgebrochen. +No\ PIN\ is\ set\ on\ this\ device.=Auf diesem Ger\u00e4t ist keine PIN festgelegt. +The\ PIN\ vault\ is\ damaged.=Der PIN-Tresor ist besch\u00e4digt. +The\ PIN\ vault\ could\ not\ be\ read.=Der PIN-Tresor konnte nicht gelesen werden. +The\ PIN\ could\ not\ be\ saved.=Die PIN konnte nicht gespeichert werden. +The\ old\ PIN\ could\ not\ be\ removed.=Die alte PIN konnte nicht entfernt werden. +This\ device\ has\ no\ secure\ storage\ for\ the\ PIN.=Dieses Ger\u00e4t hat keinen sicheren Speicher f\u00fcr die PIN. +App\ key=App-Schl\u00fcssel +Refresh\ token=Refresh-Token +App\ lock=App-Sperre +Biometrics=Biometrie +A\ signed-off\ copy\ for\ an\ external\ auditor\:\ the\ file,\ who\ made\ it\ and\ when,\ and\ its\ SHA-256\ so\ it\ can\ be\ checked\ later.=Eine freigegebene Kopie f\u00fcr externe Pr\u00fcfer: die Datei, wer sie wann erstellt hat und ihr SHA-256 zur sp\u00e4teren Kontrolle. +Access\ log=Zugriffsprotokoll +Action=Aktion +Actor=Akteur +Actual=Ist +Adjustment=Korrekturbuchung +All\ entries=Alle Buchungen +Amount\ (negative\ to\ debit)=Betrag (negativ f\u00fcr Soll) +Approvals=Freigaben +Approve=Freigeben +Approved=Freigegeben +As\ of\ {0}=Stand {0} +Asset=Anlage +Assets=Aktiva +Assets\ \=\ liabilities\ +\ equity=Aktiva = Verbindlichkeiten + Eigenkapital +Audit=Pr\u00fcfung +Audit\ chain=Pr\u00fcfkette +Audit\ trail=Pr\u00fcfpfad +Audit\ trail\ of\ this\ entry=Pr\u00fcfpfad dieser Buchung +Awaiting\ approval=Wartet auf Freigabe +Balance=Saldo +Balance\ after=Saldo danach +Balance\ sheet=Bilanz +Balance,\ 30\ days=Saldo, 30 Tage +Balanced=Ausgeglichen +Breaks=Differenzen +Broken\ at\ \#{0}=Gebrochen bei #{0} +CSV=CSV +Capital=Kapital +Class=Klasse +Close\ period=Periode abschlie\u00dfen +Close\ the\ books\ through\ now=B\u00fccher bis jetzt abschlie\u00dfen +Closed\ at=Abgeschlossen am +Closed\ by=Abgeschlossen von +Closed\ through=Abgeschlossen bis +Closing\ a\ period\ moves\ revenue\ and\ expense\ into\ retained\ earnings\ and\ locks\ every\ entry\ up\ to\ its\ date.=Ein Periodenabschluss bucht Ertr\u00e4ge und Aufw\u00e4nde in die Gewinnr\u00fccklage und sperrt alle Buchungen bis zu seinem Datum. +Closing\ balance=Schlusssaldo +Closing\ entry=Abschlussbuchung +Control=Sammelkonto +Could\ not\ save\ the\ file.=Die Datei konnte nicht gespeichert werden. +Credit\ (Cr)=Haben (H) +Credit\ to\ your\ account=Gutschrift auf Ihr Konto +Crypto\ withdrawal=Krypto-Auszahlung +Date=Datum +Debit\ (Dr)=Soll (S) +Debit\ from\ your\ account=Belastung Ihres Kontos +Decide=Entscheiden +Description=Beschreibung +Difference=Differenz +Dollar\ balance,\ 30\ days=Dollar-Saldo, 30 Tage +Dollar\ volume,\ 30\ days=Dollar-Volumen, 30 Tage +Each\ control\ account\ against\ the\ sum\ of\ its\ subledger.\ A\ difference\ is\ a\ break\ and\ is\ shown\ in\ red.=Jedes Sammelkonto gegen die Summe seines Nebenbuchs. Eine Differenz ist ein Fehler und wird rot angezeigt. +Each\ event's\ hash\ covers\ its\ own\ fields\ and\ the\ hash\ before\ it,\ so\ changing\ any\ row\ breaks\ every\ link\ after\ it.=Der Hash jedes Ereignisses deckt seine Felder und den vorherigen Hash ab; eine ge\u00e4nderte Zeile bricht jedes folgende Glied. +Entries=Buchungen +Entry=Buchung +Entry\ id,\ account\ id\ or\ e-mail=Buchungs-ID, Konto-ID oder E-Mail +Equity=Eigenkapital +Every\ control\ account\ agrees\ with\ its\ subledger.=Jedes Sammelkonto stimmt mit seinem Nebenbuch \u00fcberein. +Every\ entry,\ newest\ first.\ Debits\ and\ credits\ of\ an\ entry\ sum\ to\ the\ same\ amount\ in\ each\ currency.\ Tap\ an\ entry\ for\ its\ legs.=Alle Buchungen, neueste zuerst. Soll und Haben einer Buchung sind je W\u00e4hrung gleich. Tippen Sie auf eine Buchung f\u00fcr ihre Posten. +Every\ event\ that\ names\ the\ subject,\ oldest\ first.=Alle Ereignisse zu diesem Gegenstand, \u00e4lteste zuerst. +Every\ read\ of\ the\ books\ and\ the\ audit\ log\ by\ staff\ is\ itself\ an\ audit\ event.=Jeder Lesezugriff des Personals auf B\u00fccher und Pr\u00fcfprotokoll ist selbst ein Pr\u00fcfereignis. +Evidence=Nachweise +Expected=Soll-Wert +Expenses=Aufwendungen +General\ ledger=Hauptbuch +Hash=Hash +Head=Kopf +Head\ hash=Kopf-Hash +In\ dollars=In Dollar +Income\ statement=Gewinn- und Verlustrechnung +Initiated\ by=Veranlasst von +Integrity\ checks=Integrit\u00e4tspr\u00fcfungen +JSON=JSON +Journal=Journal +Journal\ entries=Journalbuchungen +Journal\ entries\ a\ day=Buchungen pro Tag +Journal\ entry\ {0}=Buchung {0} +Ledger\ entry=Buchungssatz +Liabilities=Verbindlichkeiten +Links\ checked=Gepr\u00fcfte Glieder +Made\ by\ {0},\ {1}=Erstellt von {0}, {1} +Maker=Ersteller +Maker-checker\:\ an\ adjustment\ or\ a\ reversal\ raised\ by\ one\ administrator\ is\ posted\ only\ when\ another\ approves\ it.=Vier-Augen-Prinzip: eine Korrektur oder Stornierung eines Administrators wird erst gebucht, wenn ein anderer zustimmt. +Memo=Verwendungszweck +Money\ in\ and\ out,\ 14\ days=Ein- und Ausg\u00e4nge, 14 Tage +Net\ income=Jahresergebnis +No\ breaks=Keine Differenzen +No\ entries\ yet.=Noch keine Buchungen. +No\ events.=Keine Ereignisse. +No\ period\ has\ been\ closed\ yet.=Noch keine Periode abgeschlossen. +No\ postings\ to\ this\ account\ in\ this\ currency.=Keine Buchungen auf diesem Konto in dieser W\u00e4hrung. +Nothing\ in\ this\ section.=Nichts in diesem Abschnitt. +Nothing\ waiting.=Nichts offen. +Opening\ balance=Anfangssaldo +Order\ hold=Auftragssperre +Order\ release=Auftragsfreigabe +Out\ of\ balance=Nicht ausgeglichen +Overview=\u00dcbersicht +Owner=Inhaber +Pending=Offen +Period=Periode +Period\ close=Periodenabschluss +Posted=Gebucht +Posting=Buchungsposten +Previous\ hash=Vorheriger Hash +Price,\ 24\ hours=Preis, 24 Stunden +Problem=Problem +Raise\ an\ adjustment=Korrektur anlegen +Raised=Angelegt +Read-only=Nur Lesen +Reason=Grund +Reconciliation=Abstimmung +Reject=Ablehnen +Rejected=Abgelehnt +Request\ adjustment=Korrektur beantragen +Request\ reversal=Storno beantragen +Revenue=Ertr\u00e4ge +Reversal=Storno +Reverse\ entry\ {0}=Buchung {0} stornieren +Reverse\ this\ entry=Diese Buchung stornieren +Reversed\ by=Storniert durch +Reverses=Storniert +SHA-256=SHA-256 +Saved=Gespeichert +Sent\ for\ approval.=Zur Freigabe gesendet. +Show\ as\ a\ T-account=Als T-Konto anzeigen +Show\ trail=Pfad anzeigen +Side=Seite +Staff\ member=Mitarbeiter +Statement=Kontoauszug +Status=Status +Subject=Gegenstand +Subledger=Nebenbuch +The\ other\ side\ of\ this\ entry\ is\ in\ the\ bank's\ books;\ every\ entry's\ debits\ equal\ its\ credits.=Die Gegenseite dieser Buchung steht in den B\u00fcchern der Bank; Soll und Haben jeder Buchung sind gleich. +Time=Zeit +Total=Summe +Total\ assets=Summe Aktiva +Total\ credits=Summe Haben +Total\ debits=Summe Soll +Transaction=Umsatz +Transfer=\u00dcberweisung +Trial\ balance=Saldenbilanz +Type=Art +Unavailable=Nicht verf\u00fcgbar +Valued\ in\ dollars=In Dollar bewertet +Volume\ ({0})=Volumen ({0}) +Where\ your\ money\ is=Wo Ihr Geld liegt +Withdrawal=Auszahlung +Your\ password=Ihr Passwort +credit=Haben +debit=Soll +{0}\ account,\ normal\ balance\ {1}.={0}-Konto, Normalsaldo {1}. +{0}\ breaks={0} Differenzen +{0}\ raised\ by\ {1}={0} angelegt von {1} +{0}\ to\ {1}={0} bis {1} +Showing\ the\ latest\ {0}\ of\ {1}\ postings.=Die letzten {0} von {1} Buchungen. +Cash\ in\ dollar\ accounts,\ 30\ days=Guthaben auf Dollarkonten, 30 Tage +error.account-already-has-authenticator-app=Dieses Konto hat bereits eine Authenticator-App +error.account-already-has-card=Dieses Konto hat bereits eine Karte +error.account-frozen=Dieses Konto ist eingefroren +error.add-authenticator-app=F\u00fcge zuerst eine Authenticator-App hinzu +error.address-already-list=Diese Adresse steht bereits auf deiner Liste +error.address-new-used=Diese Adresse ist neu: Sie kann ab {0} verwendet werden +error.address-wrong-asset=Diese Adresse gilt f\u00fcr {0} +error.administrators=Nur f\u00fcr Administratoren +error.amount-cannot-zero=Ein Betrag darf nicht null sein +error.amount-more-than-zero=Der Betrag muss gr\u00f6\u00dfer als null sein +error.amount-too-large=Dieser Betrag ist zu gro\u00df +error.both-accounts-hold-same-currency=Beide Konten m\u00fcssen dieselbe W\u00e4hrung f\u00fchren. Nutze die B\u00f6rse zum Umtauschen. +error.card-frozen=Diese Karte ist eingefroren +error.card-limit-range=Das Limit einer Karte liegt zwischen 0 und {0} +error.cards-issued-checking-accounts=Karten werden zu Girokonten ausgegeben +error.choose-password-least-characters=W\u00e4hle ein Passwort mit mindestens {0} Zeichen +error.choose-two-different-accounts=W\u00e4hle zwei verschiedene Konten +error.close-account-before-opening-another=Schlie\u00dfe ein Konto, bevor du ein weiteres er\u00f6ffnest +error.code-not-app-shows=Dieser Code ist nicht der, den deine App anzeigt +error.coin-withdrawn-address=Nur ein Coin wird an eine Adresse ausgezahlt +error.coins-withdrawn-wallet=Coins werden aus einem Wallet ausgezahlt +error.crypto-held-wallet-fiat-account=Krypto liegt in einem Wallet, Fiat auf einem Konto +error.customers-hold-accounts=Nur Kunden f\u00fchren Konten +error.device-not-attested=Dieses Ger\u00e4t ist nicht attestiert oder gerootet: Es kann Geld nur zwischen deinen eigenen Konten bewegen +error.e-mail-already-has-account=Diese E-Mail hat bereits ein Konto +error.enter-password-add-device-turn=Gib dein Passwort ein, um ein Ger\u00e4t hinzuzuf\u00fcgen, oder schalte eine Authenticator-App ein +error.account-locked=Zu viele falsche Passw\u00f6rter: Dieses Konto ist vor\u00fcbergehend gesperrt +error.sign-or-enter-password-revoke=Best\u00e4tige das auf einem anderen Ger\u00e4t oder gib dein Passwort ein +error.enter-password-add-app=Gib dein Passwort ein, um eine Authenticator-App hinzuzuf\u00fcgen +error.entry-needs-two-sides=Ein Eintrag braucht zwei Seiten +error.idempotency-key-reused-order=Dieser Idempotency-Key wurde bereits f\u00fcr eine andere Order verwendet +error.idempotency-key-reused=Dieser Idempotency-Key wurde bereits f\u00fcr eine andere Anfrage verwendet +error.insufficient-funds=Nicht gen\u00fcgend Guthaben +error.key-ec-p-256-public=Der Schl\u00fcssel muss ein \u00f6ffentlicher EC-P-256-Schl\u00fcssel sein +error.limit-order-names-no-budget=Eine Limit-Order nennt kein Budget +error.limit-order-needs-price=Eine Limit-Order braucht einen Preis +error.market-buy-names-budget=Ein Market-Kauf nennt nur ein Budget +error.market-sell-names-quantity=Ein Market-Verkauf nennt nur eine Menge +error.more-than-crypto-movement-may=Das ist mehr, als eine Krypto-Bewegung bewegen darf +error.more-than-payment-may-move=Das ist mehr, als eine Zahlung bewegen darf +error.move-money-between-own-accounts=Verschiebe stattdessen Geld zwischen deinen eigenen Konten +error.name-account-pay-either-account=Nenne das Konto, von dem bezahlt wird, und entweder ein Konto oder einen Empf\u00e4nger als Ziel +error.name-payee=Nenne den Empf\u00e4nger +error.name-too-long=Dieser Name ist zu lang +error.needs-device-signature=Daf\u00fcr ist eine Signatur deines Ger\u00e4ts n\u00f6tig: {0} +error.new-payee-receive-until-their=Ein neuer Empf\u00e4nger kann bis zum Ende seiner Abk\u00fchlphase nur {0} erhalten +error.no-customer-has-address=Kein Kunde hat diese Adresse +error.no-such-account=Kein solches Konto +error.no-such-address=Keine solche Adresse +error.no-such-asset=Kein solcher Verm\u00f6genswert +error.no-such-card=Keine solche Karte +error.no-such-device=Kein solches Ger\u00e4t +error.no-such-kind-account=Keine solche Kontoart +error.no-such-market=Kein solcher Markt +error.no-such-order=Keine solche Order +error.no-such-passkey=Kein solcher Passkey +error.no-such-payee=Kein solcher Empf\u00e4nger +error.not-address=Das ist keine Adresse +error.not-current-password=Das ist nicht dein aktuelles Passwort +error.not-e-mail-address=Das ist keine E-Mail-Adresse +error.not-found=Nicht gefunden +error.nothing-register=Nichts zu registrieren +error.open-checking-first=Er\u00f6ffne zuerst ein {0}-Girokonto +error.open-checking-savings-wallet-account=Er\u00f6ffne ein CHECKING-, SAVINGS- oder WALLET-Konto +error.open-wallet-first=Er\u00f6ffne zuerst ein {0}-Wallet +error.order-already-status=Diese Order ist bereits {0} +error.order-buy-sell=Eine Order ist ein BUY oder ein SELL +error.order-limit-market=Eine Order ist LIMIT oder MARKET +error.order-too-large=Diese Order ist zu gro\u00df +error.over-card-daily-limit=Das \u00fcberschreitet das Tageslimit der Karte +error.password-cannot-e-mail-address=Dein Passwort darf nicht deine E-Mail-Adresse sein +error.password-too-long=Dieses Passwort ist zu lang +error.payee-already-list=Dieser Empf\u00e4nger steht bereits auf deiner Liste +error.payee-cannot-receive=Dieser Empf\u00e4nger kann {0} nicht erhalten +error.quantity-whole-number-unit-lots=Die Menge muss eine ganze Zahl von Losen zu je {0} Einheiten sein +error.remove-address-before-adding-another=Entferne eine Adresse, bevor du eine weitere hinzuf\u00fcgst +error.remove-device-before-adding-another=Entferne ein Ger\u00e4t, bevor du ein weiteres hinzuf\u00fcgst +error.remove-payee-before-adding-another=Entferne einen Empf\u00e4nger, bevor du einen weiteren hinzuf\u00fcgst +error.say-which-account-open=Gib an, welches Konto er\u00f6ffnet werden soll +error.send-address-amount=Sende die Adresse und den Betrag +error.send-address=Sende die Adresse +error.send-device-public-key=Sende den \u00f6ffentlichen Schl\u00fcssel des Ger\u00e4ts +error.send-idempotency-key-16-80=Sende einen Idempotency-Key mit 16 bis 80 Zeichen +error.send-limit=Sende das Limit +error.send-order=Sende die Order +error.sign-again-add-device-sign=Melde dich erneut an, um ein Ger\u00e4t hinzuzuf\u00fcgen: Die Anmeldung darf nur wenige Minuten alt sein +error.sign-in-first=Zuerst anmelden +error.tell-us-name=Nenne uns deinen Namen +error.there-no-price=F\u00fcr {0} gibt es keinen Preis +error.withdraw-address-list=Hebe nur an eine Adresse auf deiner Liste ab +error.would-pass-crypto-limit-day=Das w\u00fcrde dein Krypto-Tageslimit \u00fcberschreiten +error.would-pass-limit-day=Das w\u00fcrde dein Tageslimit \u00fcberschreiten +Language=Sprache +Bastion\ cannot\ start=Bastion kann nicht starten +The\ network\ protection\ could\ not\ be\ installed,\ so\ Bastion\ will\ not\ connect.\ Reinstall\ the\ app.=Der Netzwerkschutz konnte nicht installiert werden, daher verbindet sich Bastion nicht. Installiere die App neu. +A\ payee=Ein Empf\u00e4nger +A\ vault\ opened\ by\ the\ PIN\ (PBKDF2,\ {0}\ rounds,\ peppered\ from\ the\ OS\ store);\ {1}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys=Ein Tresor, den die PIN \u00f6ffnet (PBKDF2, {0} Runden, gepfeffert aus dem Systemspeicher); {1} Fehlversuche in Folge l\u00f6schen die Schl\u00fcssel +A\ vault\ opened\ by\ the\ PIN\ (PBKDF2,\ {0}\ rounds,\ peppered\ from\ the\ OS\ store);\ {1}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys.\ Wrong\ tries\ so\ far\:\ {2}=Ein Tresor, den die PIN \u00f6ffnet (PBKDF2, {0} Runden, gepfeffert aus dem Systemspeicher); {1} Fehlversuche in Folge l\u00f6schen die Schl\u00fcssel. Bisherige Fehlversuche: {2} +Active=Aktiv +After\ {0}s\ idle\ or\ {1}s\ in\ the\ background=Nach {0} s Leerlauf oder {1} s im Hintergrund +AppShield\ engine\ {0}\:\ {1}=AppShield-Engine {0}: {1} +Available;\ turn\ it\ on\ in\ Settings=Verf\u00fcgbar; in den Einstellungen einschalten +Biometric\ copy\ simulated\:\ a\ file\ gated\ by\ the\ simulator's\ Biometric\ Simulation\ menu=Biometrische Kopie simuliert: eine Datei, die das Men\u00fc Biometric Simulation des Simulators sch\u00fctzt +Can\ read\ the\ screen\:\ {0}=K\u00f6nnen den Bildschirm lesen: {0} +Cash\ withdrawal=Barabhebung +Cloud\ build=Cloud-Build +Copied\ numbers\ are\ wiped\ after\ {0}s=Kopierte Nummern werden nach {0} s gel\u00f6scht +Declared\ {0};\ applied\ by\ the\ build\ server=Deklariert {0}; vom Build-Server angewendet +Deposit=Einzahlung +Development\ server\ {0}\ is\ exempt\:\ plain\ HTTP\ to\ this\ machine\ only=Der Entwicklungsserver {0} ist ausgenommen: einfaches HTTP nur zu diesem Rechner +Enter\ an\ amount,\ like\ {0}.=Geben Sie einen Betrag ein, z. B. {0}. +Every\ entry\ balances\ and\ every\ balance\ matches\ its\ postings.=Jede Buchung ist ausgeglichen und jeder Saldo stimmt mit seinen Posten \u00fcberein. +Every\ request\ is\ https;\ http\ is\ refused\ ({0}\ refused)=Jede Anfrage ist https; http wird abgelehnt ({0} abgelehnt) +Keychain\ item\ with\ biometric\ access\ control\ (current\ set)\:\ released\ only\ after\ Face\ ID\ or\ Touch\ ID=Schl\u00fcsselbund-Eintrag mit biometrischer Zugriffskontrolle (aktueller Satz): nur nach Face ID oder Touch ID freigegeben +Keystore\ key\ that\ requires\ user\ authentication\:\ released\ only\ after\ a\ strong\ biometric=Keystore-Schl\u00fcssel mit Benutzerauthentifizierung: nur nach starker Biometrie freigegeben +Level\ {0},\ mapping\ {1}=Stufe {0}, Mapping {1} +Limit=Limit +Market=Markt +My\ accounts=Meine Konten +No\ such\ account=Kein solches Konto +None\ enrolled\:\ the\ PIN\ or\ the\ password\ unlocks\ instead=Keine eingerichtet: stattdessen entsperrt die PIN oder das Passwort +Not\ in\ simulator=Nicht im Simulator +Not\ set\:\ Settings,\ Unlock\ PIN=Nicht festgelegt: Einstellungen, Entsperr-PIN +On\ start,\ on\ return\ and\ when\ idle\:\ PIN=Beim Start, bei der R\u00fcckkehr und im Leerlauf: PIN +On\ start,\ on\ return\ and\ when\ idle\:\ biometric=Beim Start, bei der R\u00fcckkehr und im Leerlauf: Biometrie +On\ start,\ on\ return\ and\ when\ idle\:\ biometric,\ then\ PIN=Beim Start, bei der R\u00fcckkehr und im Leerlauf: Biometrie, dann PIN +On\ start,\ on\ return\ and\ when\ idle\:\ the\ password=Beim Start, bei der R\u00fcckkehr und im Leerlauf: das Passwort +Only\ {0}=Nur {0} +Opened\ only\ by\ the\ unlock\ PIN=Nur mit der Entsperr-PIN zu \u00f6ffnen +Opened\ without\ asking\:\ set\ up\ a\ biometric\ or\ a\ PIN=\u00d6ffnet ohne Nachfrage: richten Sie Biometrie oder eine PIN ein +P-256,\ registered\ as\ {0};\ signs\ every\ payment=P-256, registriert als {0}; signiert jede Zahlung +Released\ by\ the\ platform\ after\ a\ biometric=Von der Plattform nach Biometrie freigegeben +Sealed\ under\ the\ app\ key,\ which\ opens\ without\ asking\ until\ a\ biometric\ or\ PIN\ is\ set\ up=Mit dem App-Schl\u00fcssel versiegelt, der ohne Nachfrage \u00f6ffnet, bis Biometrie oder eine PIN eingerichtet ist +Sealed\ under\ the\ app\ key\:\ unreadable\ until\ the\ lock\ is\ opened=Mit dem App-Schl\u00fcssel versiegelt: unlesbar, bis die Sperre ge\u00f6ffnet ist +The\ audit\ chain\ is\ intact.=Die Pr\u00fcfkette ist intakt. +The\ PIN\ could\ not\ be\ removed.=Die PIN konnte nicht entfernt werden. +This\ device\ looks\ modified\:\ {0}.\ The\ bank\ will\ not\ move\ money\ from\ it.=Dieses Ger\u00e4t scheint ver\u00e4ndert: {0}. Die Bank bewegt von ihm kein Geld. +This\ platform\ has\ no\ biometric-bound\ store\:\ set\ up\ a\ PIN=Diese Plattform hat keinen biometriegebundenen Speicher: richten Sie eine PIN ein +Try\ again=Erneut versuchen +Unlock\ with\ a\ biometric\ is\ on=Entsperren per Biometrie ist an +Warning=Warnung +android.playIntegrity\={0}=android.playIntegrity={0} +ios.blockScreenshotsOnEnterBackground\={0}=ios.blockScreenshotsOnEnterBackground={0} +ios.detectJailbreak\={0}=ios.detectJailbreak={0} +{0}\ hosts\ pinned={0} Hosts gepinnt +{0}\ hosts\ pinned,\ {1}\ mismatches\ refused={0} Hosts gepinnt, {1} Abweichungen abgelehnt +{0}\ hosts,\ version\ {1}={0} Hosts, Version {1} +{0}\ hosts,\ version\ {1}\ (refresh\ due)={0} Hosts, Version {1} (Aktualisierung f\u00e4llig) +error.price-outside-collar=Der Preis muss innerhalb von {0} % des Indexpreises liegen +error.no-such-entry=Keine solche Journalbuchung +error.entry-cannot-reversed=Diese Buchung kann nicht storniert werden +error.entry-already-reversed=Diese Buchung wurde bereits storniert +error.period-closed=Dieses Datum liegt in einer abgeschlossenen Periode +error.period-end-past-last-close=Eine Periode muss in der Vergangenheit enden, nach der zuletzt abgeschlossenen +error.four-eyes-second-administrator=Ein zweiter Administrator muss dies genehmigen +error.approval-not-pending=Diese Anfrage wartet nicht mehr auf Genehmigung +error.staff-only=Nur f\u00fcr Mitarbeiter +error.enter-password-confirm=Gib dein Passwort zur Best\u00e4tigung ein +error.not-number=Das ist keine Zahl +error.name-entity=Gib die zu verfolgende Entit\u00e4t an +error.format-csv-json=Das Format ist csv oder json +Opening=Anfang +Closing=Ende +Posted.=Gebucht. +Above\ the\ approval\ threshold,\ a\ second\ administrator\ must\ approve\ it.=\u00dcber dem Freigabeschwellenwert muss ein zweiter Administrator zustimmen. +Only\ the\ latest\ {0}\ postings\ are\ listed;\ the\ totals\ and\ balances\ cover\ them\ all.=Nur die letzten {0} Buchungen sind aufgef\u00fchrt; Summen und Salden umfassen alle. +Receipt=Beleg +Completed=Abgeschlossen +Fee=Geb\u00fchr +Category=Kategorie +Show=Anzeigen +Columns=Spalten +Card=Karte +Other=Sonstiges +Card\ details=Kartendetails +Virtual\ debit=Virtuelle Debitkarte +Expires=G\u00fcltig bis +Cardholder=Karteninhaber +{0}\ used\ today,\ {1}\ left={0} heute genutzt, {1} verf\u00fcgbar +Recent\ card\ payments=Letzte Kartenzahlungen +This\ card\ has\ not\ been\ used\ yet.=Diese Karte wurde noch nicht benutzt. +Account\ ID=Konto-ID +No\ movements\ in\ this\ period.=Keine Buchungen in diesem Zeitraum. +Card\ ID=Karten-ID +Entry\ id=Buchungs-ID +Currency=W\u00e4hrung +Books=B\u00fccher +All\ accounts=Alle Konten +As\ of=Stand +File=Datei +Made\ by=Erstellt von +Generated=Erstellt +Print=Drucken +Printing\ is\ not\ available\ on\ this\ device.=Drucken ist auf diesem Ger\u00e4t nicht verf\u00fcgbar. +Could\ not\ print\:\ {0}=Drucken fehlgeschlagen: {0} +Sent\ to\ the\ printer.=An den Drucker gesendet. +Page\ {0}\ of\ {1}=Seite {0} von {1} +{0}\ (continued)={0} (Fortsetzung) +Device\ key\ ID=Ger\u00e4teschl\u00fcssel-ID +Passkey\ ID=Passkey-ID +Copy\ {0}={0} kopieren +Copied=Kopiert +Or\ sign\ in\ with\ your\ e-mail\ and\ password.\ An\ account\ with\ a\ passkey\ takes\ its\ password\ only\ with\ a\ recovery\ code.=Oder melden Sie sich mit E-Mail und Passwort an. Ein Konto mit Passkey nimmt das Passwort nur zusammen mit einem Wiederherstellungscode an. +This\ account\ signs\ in\ with\ its\ passkey.\ Use\ the\ passkey,\ or\ ask\ the\ bank\ to\ help\ you\ recover\ the\ account.=Dieses Konto meldet sich mit seinem Passkey an. Verwenden Sie den Passkey oder bitten Sie die Bank, Ihnen bei der Wiederherstellung zu helfen. +This\ account\ signs\ in\ with\ its\ passkey.\ Without\ it,\ enter\ one\ of\ your\ recovery\ codes.=Dieses Konto meldet sich mit seinem Passkey an. Ohne ihn geben Sie einen Ihrer Wiederherstellungscodes ein. +Custody=Verwahrung +Held\ on\ chain\ {0}=Auf der Chain {0} +Owed\ to\ customers\ {0}=Kunden geschuldet {0} +Hot\ wallet=Hot Wallet +In\ transit=Unterwegs +Withdrawals\ not\ yet\ confirmed=Noch nicht best\u00e4tigte Auszahlungen +Receive\ crypto=Krypto empfangen +Chain\ {0}=Chain {0} +Your\ {0}\ address=Ihre {0}-Adresse +Credited\ after\ {0}\ confirmations,\ usually\ a\ few\ minutes.=Gutgeschrieben nach {0} Best\u00e4tigungen, meist in wenigen Minuten. +On\ the\ chain=Auf der Chain +Nothing\ has\ crossed\ the\ chain\ yet.=Noch nichts \u00fcber die Chain gegangen. +To\ {0}=An {0} +Final=Endg\u00fcltig +Failed=Fehlgeschlagen +Confirming=Wird best\u00e4tigt +Sending=Wird gesendet +Set\ up\ a\ second\ factor=Zweiten Faktor einrichten +Open\ Settings=Einstellungen \u00f6ffnen +error.too-many-new-addresses=Sie haben heute so viele Adressen hinzugef\u00fcgt wie erlaubt; versuchen Sie es morgen wieder +error.second-factor-add-device=Richten Sie eine Authenticator-App oder einen Passkey ein und melden Sie sich damit an, um ein Ger\u00e4t hinzuzuf\u00fcgen +error.too-many-new-payees=Sie haben heute so viele Empf\u00e4nger hinzugef\u00fcgt wie erlaubt; versuchen Sie es morgen wieder diff --git a/scripts/bastion/common/src/main/l10n/Bundle_en.properties b/scripts/bastion/common/src/main/l10n/Bundle_en.properties new file mode 100644 index 00000000000..73e3cc58658 --- /dev/null +++ b/scripts/bastion/common/src/main/l10n/Bundle_en.properties @@ -0,0 +1,751 @@ +# Bastion in English. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +# To translate: copy this file to Bundle_.properties, translate the +# values (never the keys), and add to Lang.CODES. Refusals the server +# sends carry a stable code and are the error. lines at the end. +@rtl=false +language.name=English + +Account=Account +Frozen=Frozen +Move\ money=Move money +Send=Send +Add\ money=Add money +Withdraw=Withdraw +Order\ a\ card=Order a card +No\ card=No card +History=History +Nothing\ has\ moved\ in\ this\ account\ yet.=Nothing has moved in this account yet. +Money\ in=Money in +Money\ out=Money out +Amount=Amount +Add=Add +Withdraw\ cash=Withdraw cash +Cancel=Cancel +Not\ done=Not done +Administration=Administration +Administrators\ only.=Administrators only. +Recomputed\ from\ the\ stored\ rows\ each\ time\ you\ ask.=Recomputed from the stored rows each time you ask. +Verify\ the\ audit\ log=Verify the audit log +Verify\ the\ ledger=Verify the ledger +Audit\ log=Audit log +Ledger=Ledger +Intact=Intact +Problems\ found=Problems found +Cards=Cards +No\ cards\ yet.\ Order\ one\ from\ a\ checking\ account.=No cards yet. Order one from a checking account. +Bastion=Bastion +Spends\ up\ to\ {0}\ a\ day=Spends up to {0} a day +Unfreeze=Unfreeze +Freeze=Freeze +Not\ changed=Not changed +Daily\ limit=Daily limit +Save=Save +Sign\ in\ first=Sign in first +Password=Password +Continue=Continue +Confirm\ it\ is\ you=Confirm it is you +Sign\ with\ this\ device=Sign with this device +Payments\ leaving\ your\ accounts\ are\ signed\ by\ a\ key\ that\ never\ leaves\ this\ phone.\ Register\ this\ device\ to\ continue.=Payments leaving your accounts are signed by a key that never leaves this phone. Register this device to continue. +Register=Register +Not\ now=Not now +Exchange=Exchange +Markets=Markets +No\ markets\ are\ open.=No markets are open. +Index\ {0}=Index {0} +Your\ trades=Your trades +You\ have\ not\ traded\ yet.=You have not traded yet. +Welcome=Welcome +Hello,\ {0}=Hello, {0} +Total\ balance=Total balance +Not\ counted,\ no\ dollar\ price\:\ {0}=Not counted, no dollar price: {0} +Pay\ someone=Pay someone +Trade=Trade +Accounts=Accounts +You\ have\ no\ accounts\ yet.=You have no accounts yet. +Open\ an\ account=Open an account +Checking=Checking +Savings=Savings +Wallet=Wallet +This\ device\ cannot\ sign\ payments\ yet=This device cannot sign payments yet +Payments\ to\ other\ people\ and\ crypto\ withdrawals\ need\ a\ key\ that\ lives\ only\ on\ this\ phone.=Payments to other people and crypto withdrawals need a key that lives only on this phone. +Register\ this\ device=Register this device +{0}\ wallet={0} wallet +{0}\ checking={0} checking +{0}\ savings={0} savings +Name\ (optional)=Name (optional) +Open=Open +Not\ opened=Not opened +Locked=Locked +Bastion\ is\ locked=Bastion is locked +Unlock=Unlock +Sign\ in\ again=Sign in again +Not\ recognised.\ Try\ again.=Not recognised. Try again. +Last\ price=Last price +Order\ book=Order book +Place\ an\ order=Place an order +Your\ open\ orders=Your open orders +Bid\ {0}\ -\ Ask\ {1}=Bid {0} - Ask {1} +The\ book\ is\ empty.=The book is empty. +Buy=Buy +Sell=Sell +at\ {0},\ {1}\ filled=at {0}, {1} filled +No\ open\ orders\ in\ this\ market.=No open orders in this market. +Price\ in\ {0}=Price in {0} +Amount\ of\ {0}=Amount of {0} +Spend,\ in\ {0}=Spend, in {0} +In\ steps\ of\ {0}=In steps of {0} +Place\ order=Place order +Confirm\ order=Confirm order +Place=Place +Order\ {0}=Order {0} +Enter\ how\ much\ to\ spend.=Enter how much to spend. +Enter\ an\ amount\ in\ steps\ of\ {0}.=Enter an amount in steps of {0}. +Enter\ a\ price.=Enter a price. +Buy\ {0}\ at\ the\ best\ prices,\ spending\ up\ to\ {1}?=Buy {0} at the best prices, spending up to {1}? +Sell\ {0}\ at\ the\ best\ prices?=Sell {0} at the best prices? +Buy\ {0}\ at\ {1}\ (about\ {2})?=Buy {0} at {1} (about {2})? +Sell\ {0}\ at\ {1}\ (about\ {2})?=Sell {0} at {1} (about {2})? +filled=filled +on\ the\ book=on the book +ended,\ the\ rest\ unfilled=ended, the rest unfilled +Bought\ {0}=Bought {0} +Sold\ {0}=Sold {0} +at\ {0},\ fee\ {1}=at {0}, fee {1} +Payees=Payees +Send\ crypto=Send crypto +Security\ Center=Security Center +Settings=Settings +Sign\ out=Sign out +Bank\ with\ Bastion=Bank with Bastion +Takes\ two\ minutes.\ You\ will\ set\ up\ a\ second\ factor\ and\ this\ phone's\ signing\ key\ as\ you\ go.=Takes two minutes. You will set up a second factor and this phone's signing key as you go. +Your\ name=Your name +E-mail=E-mail +Password\ again=Password again +Create\ account=Create account +At\ least\ {0}\ characters=At least {0} characters +Both\ passwords\ match=Both passwords match +Not\ your\ e-mail\ address=Not your e-mail address +A\ few\ unrelated\ words\ make\ a\ password\ that\ is\ long\ and\ easy\ to\ remember.=A few unrelated words make a password that is long and easy to remember. +Sign\ in\ to\ continue.=Sign in to continue. +Authenticator\ app=Authenticator app +Add\ a\ second\ factor=Add a second factor +Scan\ this\ with\ an\ authenticator\ app.\ From\ now\ on\ a\ sign-in\ with\ your\ password\ also\ asks\ for\ the\ code\ it\ shows.=Scan this with an authenticator app. From now on a sign-in with your password also asks for the code it shows. +Or\ enter\ this\ key=Or enter this key +Copy\ key=Copy key +Copy\ codes=Copy codes +Copied.\ The\ clipboard\ clears\ itself\ in\ a\ minute.=Copied. The clipboard clears itself in a minute. +Open\ authenticator=Open authenticator +Six-digit\ code=Six-digit code +Turn\ on=Turn on +New\ recovery\ codes=New recovery codes +Recovery\ codes=Recovery codes +Save\ your\ recovery\ codes=Save your recovery codes +Each\ signs\ you\ in\ once\ if\ you\ lose\ your\ phone.\ Keep\ them\ somewhere\ safe\ and\ offline.\ They\ will\ not\ be\ shown\ again.=Each signs you in once if you lose your phone. Keep them somewhere safe and offline. They will not be shown again. +I\ have\ saved\ them=I have saved them +Passkey=Passkey +Sign\ in\ with\ a\ passkey=Sign in with a passkey +A\ passkey\ is\ kept\ by\ your\ phone\ and\ unlocked\ by\ your\ face,\ fingerprint\ or\ screen\ lock.\ It\ cannot\ be\ phished,\ and\ replaces\ both\ the\ password\ and\ the\ code.=A passkey is kept by your phone and unlocked by your face, fingerprint or screen lock. It cannot be phished, and replaces both the password and the code. +Create\ a\ passkey=Create a passkey +Bastion\ on\ {0}=Bastion on {0} +Unlock\ with\ your\ face\ or\ fingerprint?=Unlock with your face or fingerprint? +Bastion\ locks\ itself\ when\ you\ leave\ it.\ With\ this\ on,\ a\ biometric\ unlocks\ it;\ with\ it\ off,\ you\ sign\ in\ again.\ The\ device\ passcode\ is\ never\ accepted.=Bastion locks itself when you leave it. With this on, a biometric unlocks it; with it off, you sign in again. The device passcode is never accepted. +Use\ biometrics=Use biometrics +This\ device=This device +Let\ this\ phone\ sign\ your\ payments=Let this phone sign your payments +Bastion\ makes\ a\ key\ on\ this\ phone\ and\ gives\ the\ bank\ only\ its\ public\ half.\ Payments\ to\ other\ people\ and\ crypto\ withdrawals\ must\ carry\ its\ signature,\ so\ a\ stolen\ password\ alone\ cannot\ move\ your\ money.=Bastion makes a key on this phone and gives the bank only its public half. Payments to other people and crypto withdrawals must carry its signature, so a stolen password alone cannot move your money. +Later=Later +Add\ a\ payee=Add a payee +No\ payees\ yet.\ Add\ one\ by\ the\ e-mail\ address\ they\ bank\ with.=No payees yet. Add one by the e-mail address they bank with. +New\ payee\:\ limited\ until\ {0}=New payee: limited until {0} +Cooling\ off=Cooling off +Ready=Ready +Their\ e-mail=Their e-mail +Name\ to\ show=Name to show +Payee\ added.\ It\ is\ limited\ while\ it\ cools\ off.=Payee added. It is limited while it cools off. +Not\ added=Not added +Rename=Rename +Remove=Remove +Remove\ payee=Remove payee +Remove\ {0}?\ Adding\ them\ again\ starts\ a\ new\ cooling-off.=Remove {0}? Adding them again starts a new cooling-off. +Done=Done +Sent=Sent +Reference=Reference +When=When +Note=Note +What\ protects\ your\ account\ on\ this\ device.\ Nothing\ here\ is\ decoration\:\ each\ line\ is\ read\ from\ the\ app\ as\ it\ runs.=What protects your account on this device. Nothing here is decoration: each line is read from the app as it runs. +Back=Back +Unlock\ with\ fingerprint\ or\ face=Unlock with fingerprint or face +This\ device\ has\ no\ biometric\ enrolled.\ The\ lock\ asks\ for\ your\ password.=This device has no biometric enrolled. The lock asks for your password. +Off=Off +Sign-in=Sign-in +{0}\ recovery\ codes\ left={0} recovery codes left +Adds\ a\ six-digit\ code\ to\ every\ password\ sign-in.=Adds a six-digit code to every password sign-in. +On=On +Payment\ signing\ key=Payment signing key +Payments\ from\ this\ phone\ are\ signed\ by\ a\ key\ that\ never\ leaves\ it.=Payments from this phone are signed by a key that never leaves it. +Register\ this\ device\ to\ sign\ payments.=Register this device to sign payments. +Sign\ in=Sign in +Your\ bank,\ locked\ down.=Your bank, locked down. +Verification=Verification +Enter\ your\ code=Enter your code +Open\ your\ authenticator\ app\ and\ enter\ the\ six-digit\ code,\ or\ one\ of\ your\ recovery\ codes.=Open your authenticator app and enter the six-digit code, or one of your recovery codes. +Code=Code +You\ have\ no\ account\ that\ can\ send\ money.=You have no account that can send money. +Add\ a\ payee\ first,\ under\ Payees.=Add a payee first, under Payees. +Note\ (optional)=Note (optional) +Review=Review +From=From +To=To +Choose\ who\ to\ pay.=Choose who to pay. +That\ is\ more\ than\ this\ account\ holds.=That is more than this account holds. +Choose\ two\ different\ accounts.=Choose two different accounts. +Confirm=Confirm +Send\ {0}\ from\ {1}\ to\ {2}?=Send {0} from {1} to {2}? +Loading...=Loading... +Open\ a\ crypto\ wallet\ to\ send\ coins\ from.=Open a crypto wallet to send coins from. +Your\ addresses=Your addresses +Add\ an\ address=Add an address +No\ addresses\ yet.\ Coins\ can\ be\ sent\ only\ to\ an\ address\ on\ this\ list.=No addresses yet. Coins can be sent only to an address on this list. +Usable\ from\ {0}=Usable from {0} +Waiting=Waiting +No\ {0}\ address\ is\ ready\ yet.=No {0} address is ready yet. +Choose\ an\ address.=Choose an address. +Enter\ an\ amount.=Enter an amount. +That\ is\ more\ than\ this\ wallet\ holds.=That is more than this wallet holds. +Send\ {0}\ to\ {1}?\ This\ cannot\ be\ undone.=Send {0} to {1}? This cannot be undone. +Address=Address +Label,\ like\ My\ hardware\ wallet=Label, like My hardware wallet +Address\ added=Address added +For\ your\ safety\ it\ can\ be\ used\ from\ {0}.=For your safety it can be used from {0}. +Remove\ address=Remove address +Remove\ {0}?\ Adding\ it\ again\ starts\ a\ new\ wait.=Remove {0}? Adding it again starts a new wait. +Not\ removed=Not removed +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=Could not reach the server. Check your connection. +Something\ went\ wrong\ ({0}).\ Try\ again.=Something went wrong ({0}). Try again. +The\ server\ changed.\ Sign\ in\ again.=The server changed. Sign in again. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=Too many attempts. Wait a minute and try again. +This\ account\ is\ locked\ for\ a\ while\ after\ too\ many\ attempts.=This account is locked for a while after too many attempts. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=That e-mail and password do not match an account. +That\ code\ is\ not\ right.\ Codes\ change\ every\ 30\ seconds.=That code is not right. Codes change every 30 seconds. +Passkey\ sign-in\ is\ not\ available\ right\ now.=Passkey sign-in is not available right now. +That\ passkey\ was\ not\ accepted.=That passkey was not accepted. +Passkey\ sign-in\ was\ cancelled.=Passkey sign-in was cancelled. +Signing\ in\ failed.\ Try\ again.=Signing in failed. Try again. +Sign\ in\ again\ to\ add\ a\ passkey.=Sign in again to add a passkey. +Passkeys\ are\ not\ available\ right\ now.=Passkeys are not available right now. +That\ passkey\ was\ not\ saved.=That passkey was not saved. +Creating\ the\ passkey\ was\ cancelled.=Creating the passkey was cancelled. +This\ device\ has\ no\ secure\ storage\ for\ the\ key.=This device has no secure storage for the key. +Unlock\ Bastion=Unlock Bastion +Unlock\ Bastion\ and\ the\ keys\ that\ sign\ your\ payments=Unlock Bastion and the keys that sign your payments +Network=Network +Keys\ and\ sign-in=Keys and sign-in +Build\ hardening=Build hardening +No\ cleartext=No cleartext +Development\ server\ =Development server +\ is\ exempt\:\ plain\ HTTP\ to\ this\ machine\ only=\ is exempt: plain HTTP to this machine only +Every\ request\ is\ https;\ http\ is\ refused=Every request is https; http is refused +Host\ allow-list=Host allow-list +Exempt\ while\ the\ development\ server\ is\ in\ use=Exempt while the development server is in use +Only\ =Only +Over-the-air\ pins=Over-the-air pins +Published\ by\ the\ attestation\ service;\ none\ in\ this\ build=Published by the attestation service; none in this build +\ hosts,\ version\ =\ hosts, version +\ (refresh\ due)=\ (refresh due) +App\ attestation\ token=App attestation token +AppShield\ engine\ =AppShield engine +AppShield\ needs\ the\ enterprise\ engine\ in\ a\ cloud\ build;\ inert\ here=AppShield needs the enterprise engine in a cloud build; inert here +Device\ signing\ key=Device signing key +P-256,\ registered\ as\ =P-256, registered as +;\ signs\ every\ payment=; signs every payment +Not\ registered\:\ payments\ that\ need\ a\ signature\ are\ refused=Not registered: payments that need a signature are refused +Idempotency\ keys=Idempotency keys +Each\ money\ action\ carries\ one,\ so\ a\ retry\ never\ pays\ twice=Each money action carries one, so a retry never pays twice +Second\ factor\ and\ passkeys=Second factor and passkeys +Authenticator\ codes\ and\ passkeys=Authenticator codes and passkeys +Authenticator\ codes\ (no\ passkeys\ on\ this\ platform)=Authenticator codes (no passkeys on this platform) +Biometric\ lock=Biometric lock +On\ start,\ on\ return\ and\ when\ idle=On start, on return and when idle +No\ biometrics\ enrolled\:\ the\ lock\ asks\ for\ the\ password\ instead=No biometrics enrolled: the lock asks for the password instead +Auto-lock=Auto-lock +After\ =After +s\ idle\ or\ =s idle or +s\ in\ the\ background=s in the background +Platform\ attestation=Platform attestation +Play\ Integrity=Play Integrity +App\ Attest=App Attest +Play\ Integrity\ /\ App\ Attest\ need\ a\ device\ build=Play Integrity / App Attest need a device build +Accessibility\ services=Accessibility services +Can\ read\ the\ screen\:\ =Can read the screen: +None\ that\ can\ read\ the\ screen=None that can read the screen +Tapjacking=Tapjacking +Touches\ through\ overlays\ are\ dropped=Touches through overlays are dropped +Policy\ set;\ only\ Android\ has\ overlays=Policy set; only Android has overlays +Screenshot\ blocking=Screenshot blocking +Set\ on\ sensitive\ screens;\ the\ simulator\ cannot\ block\ captures=Set on sensitive screens; the simulator cannot block captures +Sensitive\ screens,\ and\ the\ app\ switcher\ card=Sensitive screens, and the app switcher card +Sensitive\ screens\ and\ the\ recent-apps\ card=Sensitive screens and the recent-apps card +Clipboard\ clearing=Clipboard clearing +Copied\ numbers\ are\ wiped\ after\ =Copied numbers are wiped after +Code\ hardening=Code hardening +Level\ =Level +,\ mapping\ =, mapping +Declared\ =Declared +;\ applied\ by\ the\ build\ server=; applied by the build server +Jailbreak\ exit\ (iOS)=Jailbreak exit (iOS) +App\ switcher\ blanking\ (iOS)=App switcher blanking (iOS) +Play\ Integrity\ (Android)=Play Integrity (Android) +Certificate\ pinning=Certificate pinning +Not\ applied\ to\ the\ development\ server\ (it\ has\ no\ certificate)=Not applied to the development server (it has no certificate) +Placeholder\ pins\:\ put\ the\ server's\ SPKI\ digests\ in\ AppConfig.PINS=Placeholder pins: put the server's SPKI digests in AppConfig.PINS +\ hosts\ pinned=\ hosts pinned +\ mismatches\ refused=\ mismatches refused +Token\ and\ key\ storage=Token and key storage +Hardware-backed\ keystore=Hardware-backed keystore +OS\ keystore=OS keystore +The\ simulator\ keeps\ them\ in\ a\ plain\ file=The simulator keeps them in a plain file +No\ keystore\ on\ this\ device\:\ tokens\ are\ not\ kept\ between\ runs=No keystore on this device: tokens are not kept between runs +Root\ and\ jailbreak\ checks=Root and jailbreak checks +Run\ on\ a\ phone;\ the\ simulator\ reports\ nothing=Run on a phone; the simulator reports nothing +This\ device\ looks\ modified\:\ =This device looks modified: +No\ signs\ of\ root\ or\ jailbreak=No signs of root or jailbreak +OK=OK +Last\ used\ {0}=Last used {0} +Add\ a\ passkey=Add a passkey +This\ device\ cannot\ create\ passkeys.=This device cannot create passkeys. +Remove\ passkey=Remove passkey +Remove\ {0}?\ It\ will\ no\ longer\ sign\ in\ to\ this\ account.=Remove {0}? It will no longer sign in to this account. +Code\ from\ your\ app=Code from your app +Authenticator\ code=Authenticator code + +# The lock, the PIN and the app key +Please\ sign\ in\ again.=Please sign in again. +Your\ account\ is\ not\ allowed\ to\ do\ that.=Your account is not allowed to do that. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=Too many attempts. Wait a moment and try again. +Unlock\ Bastion\ first.=Unlock Bastion first. +PIN=PIN +Unlock\ with\ PIN=Unlock with PIN +Not\ recognised.\ Try\ again,\ or\ use\ your\ PIN.=Not recognised. Try again, or use your PIN. +Enter\ your\ PIN\ of\ at\ least\ {0}\ digits.=Enter your PIN of at least {0} digits. +Wrong\ PIN.\ One\ try\ left\ before\ the\ keys\ on\ this\ device\ are\ erased.=Wrong PIN. One try left before the keys on this device are erased. +Wrong\ PIN.\ {0}\ tries\ left.=Wrong PIN. {0} tries left. +Too\ many\ wrong\ PINs.\ The\ keys\ on\ this\ device\ were\ erased.=Too many wrong PINs. The keys on this device were erased. +Unlock\ PIN=Unlock PIN +Choose\ an\ unlock\ PIN=Choose an unlock PIN +Set\ PIN=Set PIN +Change\ PIN=Change PIN +This\ device\ has\ no\ biometric\ enrolled.\ Set\ an\ unlock\ PIN\ instead.=This device has no biometric enrolled. Set an unlock PIN instead. +Without\ a\ biometric\ or\ a\ PIN\ the\ lock\ asks\ for\ your\ password.=Without a biometric or a PIN the lock asks for your password. +Remove\ PIN=Remove PIN +Let\ your\ biometric\ unlock\ Bastion=Let your biometric unlock Bastion +New\ PIN=New PIN +The\ same\ PIN\ again=The same PIN again +The\ two\ PINs\ are\ not\ the\ same.=The two PINs are not the same. +Choose\ 6\ to\ 12\ digits\ that\ are\ not\ a\ simple\ run.=Choose 6 to 12 digits that are not a simple run. +No\ biometric\ or\ PIN\ is\ set\ up\ on\ this\ device,\ so\ the\ way\ back\ in\ is\ your\ password.=No biometric or PIN is set up on this device, so the way back in is your password. +Signing\ in\ again\ sets\ this\ device\ up\ from\ the\ start\:\ its\ payment\ key,\ biometric\ and\ PIN\ are\ removed.=Signing in again sets this device up from the start: its payment key, biometric and PIN are removed. +Bastion\ locks\ itself\ when\ you\ leave\ it.\ With\ this\ on,\ the\ phone\ keeps\ the\ key\ to\ your\ session\ and\ payments\ where\ only\ your\ biometric\ releases\ it.\ The\ device\ passcode\ is\ never\ accepted.=Bastion locks itself when you leave it. With this on, the phone keeps the key to your session and payments where only your biometric releases it. The device passcode is never accepted. +For\ when\ a\ biometric\ is\ not\ available\ or\ you\ would\ rather\ not\ use\ one\:\ {0}\ to\ {1}\ digits,\ not\ your\ card\ PIN.\ {2}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys\ on\ this\ device.=For when a biometric is not available or you would rather not use one: {0} to {1} digits, not your card PIN. {2} wrong tries in a row erase the keys on this device. +Opens\ the\ lock\ when\ a\ biometric\ cannot.\ {0}\ wrong\ tries\ erase\ the\ keys\ on\ this\ device.=Opens the lock when a biometric cannot. {0} wrong tries erase the keys on this device. +Unlock\ first.=Unlock first. +This\ device\ could\ not\ keep\ a\ biometric-bound\ key.=This device could not keep a biometric-bound key. +This\ device\ has\ no\ biometric-bound\ key\ store.=This device has no biometric-bound key store. +Biometric\ setup\ was\ cancelled.=Biometric setup was cancelled. +No\ PIN\ is\ set\ on\ this\ device.=No PIN is set on this device. +The\ PIN\ vault\ is\ damaged.=The PIN vault is damaged. +The\ PIN\ vault\ could\ not\ be\ read.=The PIN vault could not be read. +The\ PIN\ could\ not\ be\ saved.=The PIN could not be saved. +The\ old\ PIN\ could\ not\ be\ removed.=The old PIN could not be removed. +This\ device\ has\ no\ secure\ storage\ for\ the\ PIN.=This device has no secure storage for the PIN. +App\ key=App key +Refresh\ token=Refresh token +App\ lock=App lock +Biometrics=Biometrics + +# Refusals from the server, by their stable code (api/Refusal); {0}... are +# the values the server names. The English is what the server also sends. +A\ signed-off\ copy\ for\ an\ external\ auditor\:\ the\ file,\ who\ made\ it\ and\ when,\ and\ its\ SHA-256\ so\ it\ can\ be\ checked\ later.=A signed-off copy for an external auditor: the file, who made it and when, and its SHA-256 so it can be checked later. +Access\ log=Access log +Action=Action +Actor=Actor +Actual=Actual +Adjustment=Adjustment +All\ entries=All entries +Amount\ (negative\ to\ debit)=Amount (negative to debit) +Approvals=Approvals +Approve=Approve +Approved=Approved +As\ of\ {0}=As of {0} +Asset=Asset +Assets=Assets +Assets\ \=\ liabilities\ +\ equity=Assets = liabilities + equity +Audit=Audit +Audit\ chain=Audit chain +Audit\ trail=Audit trail +Audit\ trail\ of\ this\ entry=Audit trail of this entry +Awaiting\ approval=Awaiting approval +Balance=Balance +Balance\ after=Balance after +Balance\ sheet=Balance sheet +Balance,\ 30\ days=Balance, 30 days +Balanced=Balanced +Breaks=Breaks +Broken\ at\ \#{0}=Broken at #{0} +CSV=CSV +Capital=Capital +Class=Class +Close\ period=Close period +Close\ the\ books\ through\ now=Close the books through now +Closed\ at=Closed at +Closed\ by=Closed by +Closed\ through=Closed through +Closing\ a\ period\ moves\ revenue\ and\ expense\ into\ retained\ earnings\ and\ locks\ every\ entry\ up\ to\ its\ date.=Closing a period moves revenue and expense into retained earnings and locks every entry up to its date. +Closing\ balance=Closing balance +Closing\ entry=Closing entry +Control=Control +Could\ not\ save\ the\ file.=Could not save the file. +Credit\ (Cr)=Credit (Cr) +Credit\ to\ your\ account=Credit to your account +Crypto\ withdrawal=Crypto withdrawal +Date=Date +Debit\ (Dr)=Debit (Dr) +Debit\ from\ your\ account=Debit from your account +Decide=Decide +Description=Description +Difference=Difference +Dollar\ balance,\ 30\ days=Dollar balance, 30 days +Dollar\ volume,\ 30\ days=Dollar volume, 30 days +Each\ control\ account\ against\ the\ sum\ of\ its\ subledger.\ A\ difference\ is\ a\ break\ and\ is\ shown\ in\ red.=Each control account against the sum of its subledger. A difference is a break and is shown in red. +Each\ event's\ hash\ covers\ its\ own\ fields\ and\ the\ hash\ before\ it,\ so\ changing\ any\ row\ breaks\ every\ link\ after\ it.=Each event's hash covers its own fields and the hash before it, so changing any row breaks every link after it. +Entries=Entries +Entry=Entry +Entry\ id,\ account\ id\ or\ e-mail=Entry id, account id or e-mail +Equity=Equity +Every\ control\ account\ agrees\ with\ its\ subledger.=Every control account agrees with its subledger. +Every\ entry,\ newest\ first.\ Debits\ and\ credits\ of\ an\ entry\ sum\ to\ the\ same\ amount\ in\ each\ currency.\ Tap\ an\ entry\ for\ its\ legs.=Every entry, newest first. Debits and credits of an entry sum to the same amount in each currency. Tap an entry for its legs. +Every\ event\ that\ names\ the\ subject,\ oldest\ first.=Every event that names the subject, oldest first. +Every\ read\ of\ the\ books\ and\ the\ audit\ log\ by\ staff\ is\ itself\ an\ audit\ event.=Every read of the books and the audit log by staff is itself an audit event. +Evidence=Evidence +Expected=Expected +Expenses=Expenses +General\ ledger=General ledger +Hash=Hash +Head=Head +Head\ hash=Head hash +In\ dollars=In dollars +Income\ statement=Income statement +Initiated\ by=Initiated by +Integrity\ checks=Integrity checks +JSON=JSON +Journal=Journal +Journal\ entries=Journal entries +Journal\ entries\ a\ day=Journal entries a day +Journal\ entry\ {0}=Journal entry {0} +Ledger\ entry=Ledger entry +Liabilities=Liabilities +Links\ checked=Links checked +Made\ by\ {0},\ {1}=Made by {0}, {1} +Maker=Maker +Maker-checker\:\ an\ adjustment\ or\ a\ reversal\ raised\ by\ one\ administrator\ is\ posted\ only\ when\ another\ approves\ it.=Maker-checker: an adjustment or a reversal raised by one administrator is posted only when another approves it. +Memo=Memo +Money\ in\ and\ out,\ 14\ days=Money in and out, 14 days +Net\ income=Net income +No\ breaks=No breaks +No\ entries\ yet.=No entries yet. +No\ events.=No events. +No\ period\ has\ been\ closed\ yet.=No period has been closed yet. +No\ postings\ to\ this\ account\ in\ this\ currency.=No postings to this account in this currency. +Nothing\ in\ this\ section.=Nothing in this section. +Nothing\ waiting.=Nothing waiting. +Opening\ balance=Opening balance +Order\ hold=Order hold +Order\ release=Order release +Out\ of\ balance=Out of balance +Overview=Overview +Owner=Owner +Pending=Pending +Period=Period +Period\ close=Period close +Posted=Posted +Posting=Posting +Previous\ hash=Previous hash +Price,\ 24\ hours=Price, 24 hours +Problem=Problem +Raise\ an\ adjustment=Raise an adjustment +Raised=Raised +Read-only=Read-only +Reason=Reason +Reconciliation=Reconciliation +Reject=Reject +Rejected=Rejected +Request\ adjustment=Request adjustment +Request\ reversal=Request reversal +Revenue=Revenue +Reversal=Reversal +Reverse\ entry\ {0}=Reverse entry {0} +Reverse\ this\ entry=Reverse this entry +Reversed\ by=Reversed by +Reverses=Reverses +SHA-256=SHA-256 +Saved=Saved +Sent\ for\ approval.=Sent for approval. +Show\ as\ a\ T-account=Show as a T-account +Show\ trail=Show trail +Side=Side +Staff\ member=Staff member +Statement=Statement +Status=Status +Subject=Subject +Subledger=Subledger +The\ other\ side\ of\ this\ entry\ is\ in\ the\ bank's\ books;\ every\ entry's\ debits\ equal\ its\ credits.=The other side of this entry is in the bank's books; every entry's debits equal its credits. +Time=Time +Total=Total +Total\ assets=Total assets +Total\ credits=Total credits +Total\ debits=Total debits +Transaction=Transaction +Transfer=Transfer +Trial\ balance=Trial balance +Type=Type +Unavailable=Unavailable +Valued\ in\ dollars=Valued in dollars +Volume\ ({0})=Volume ({0}) +Where\ your\ money\ is=Where your money is +Withdrawal=Withdrawal +Your\ password=Your password +credit=credit +debit=debit +{0}\ account,\ normal\ balance\ {1}.={0} account, normal balance {1}. +{0}\ breaks={0} breaks +{0}\ raised\ by\ {1}={0} raised by {1} +{0}\ to\ {1}={0} to {1} +Showing\ the\ latest\ {0}\ of\ {1}\ postings.=Showing the latest {0} of {1} postings. +Cash\ in\ dollar\ accounts,\ 30\ days=Cash in dollar accounts, 30 days +error.account-already-has-authenticator-app=This account already has an authenticator app +error.account-already-has-card=That account already has a card +error.account-frozen=That account is frozen +error.add-authenticator-app=Add an authenticator app first +error.address-already-list=That address is already on your list +error.address-new-used=That address is new: it can be used from {0} +error.address-wrong-asset=That address is for {0} +error.administrators=Administrators only +error.amount-cannot-zero=An amount cannot be zero +error.amount-more-than-zero=The amount must be more than zero +error.amount-too-large=That amount is too large +error.both-accounts-hold-same-currency=Both accounts must hold the same currency. Use the exchange to convert. +error.card-frozen=That card is frozen +error.card-limit-range=A card's limit is 0 to {0} +error.cards-issued-checking-accounts=Cards are issued on checking accounts +error.choose-password-least-characters=Choose a password of at least {0} characters +error.choose-two-different-accounts=Choose two different accounts +error.close-account-before-opening-another=Close an account before opening another +error.code-not-app-shows=That code is not the one your app shows +error.coin-withdrawn-address=Only a coin is withdrawn to an address +error.coins-withdrawn-wallet=Coins are withdrawn from a wallet +error.crypto-held-wallet-fiat-account=Crypto is held in a wallet, fiat in an account +error.customers-hold-accounts=Only customers hold accounts +error.device-not-attested=This device is not attested, or is rooted: it can move money only between your own accounts +error.e-mail-already-has-account=That e-mail already has an account +error.enter-password-add-device-turn=Enter your password to add a device, or turn on an authenticator app +error.account-locked=Too many wrong passwords: this account is locked for a while +error.sign-or-enter-password-revoke=Approve this on another device, or enter your password +error.enter-password-add-app=Enter your password to add an authenticator app +error.entry-needs-two-sides=An entry needs two sides +error.idempotency-key-reused-order=That Idempotency-Key was already used for a different order +error.idempotency-key-reused=That Idempotency-Key was already used for a different request +error.insufficient-funds=Insufficient funds +error.key-ec-p-256-public=The key must be an EC P-256 public key +error.limit-order-names-no-budget=A limit order names no budget +error.limit-order-needs-price=A limit order needs a price +error.market-buy-names-budget=A market buy names only a budget +error.market-sell-names-quantity=A market sell names only a quantity +error.more-than-crypto-movement-may=That is more than one crypto movement may move +error.more-than-payment-may-move=That is more than one payment may move +error.move-money-between-own-accounts=Move money between your own accounts instead +error.name-account-pay-either-account=Name the account to pay from, and either an account or a payee to pay +error.name-payee=Name the payee +error.name-too-long=That name is too long +error.needs-device-signature=This needs a signature from your device: {0} +error.new-payee-receive-until-their=A new payee can receive only {0} until their cooling-off period ends +error.no-customer-has-address=No customer has that address +error.no-such-account=No such account +error.no-such-address=No such address +error.no-such-asset=No such asset +error.no-such-card=No such card +error.no-such-device=No such device +error.no-such-kind-account=No such kind of account +error.no-such-market=No such market +error.no-such-order=No such order +error.no-such-passkey=No such passkey +error.no-such-payee=No such payee +error.not-address=That is not an address +error.not-current-password=That is not your current password +error.not-e-mail-address=That is not an e-mail address +error.not-found=Not found +error.nothing-register=Nothing to register +error.open-checking-first=Open a {0} checking account first +error.open-checking-savings-wallet-account=Open a CHECKING, SAVINGS or WALLET account +error.open-wallet-first=Open a {0} wallet first +error.order-already-status=That order is already {0} +error.order-buy-sell=An order is a BUY or a SELL +error.order-limit-market=An order is LIMIT or MARKET +error.order-too-large=That order is too large +error.over-card-daily-limit=That is over the card's daily limit +error.password-cannot-e-mail-address=Your password cannot be your e-mail address +error.password-too-long=That password is too long +error.payee-already-list=That payee is already on your list +error.payee-cannot-receive=That payee cannot receive {0} +error.quantity-whole-number-unit-lots=The quantity must be a whole number of {0}-unit lots +error.remove-address-before-adding-another=Remove an address before adding another +error.remove-device-before-adding-another=Remove a device before adding another +error.remove-payee-before-adding-another=Remove a payee before adding another +error.say-which-account-open=Say which account to open +error.send-address-amount=Send the address and the amount +error.send-address=Send the address +error.send-device-public-key=Send the device's public key +error.send-idempotency-key-16-80=Send an Idempotency-Key of 16 to 80 characters +error.send-limit=Send the limit +error.send-order=Send the order +error.sign-again-add-device-sign=Sign in again to add a device: the sign-in must be a few minutes old +error.sign-in-first=Sign in first +error.tell-us-name=Tell us your name +error.there-no-price=There is no price for {0} +error.withdraw-address-list=Withdraw only to an address on your list +error.would-pass-crypto-limit-day=That would pass your crypto limit for the day +error.would-pass-limit-day=That would pass your limit for the day +Language=Language +Bastion\ cannot\ start=Bastion cannot start +The\ network\ protection\ could\ not\ be\ installed,\ so\ Bastion\ will\ not\ connect.\ Reinstall\ the\ app.=The network protection could not be installed, so Bastion will not connect. Reinstall the app. +A\ payee=A payee +A\ vault\ opened\ by\ the\ PIN\ (PBKDF2,\ {0}\ rounds,\ peppered\ from\ the\ OS\ store);\ {1}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys=A vault opened by the PIN (PBKDF2, {0} rounds, peppered from the OS store); {1} wrong tries in a row erase the keys +A\ vault\ opened\ by\ the\ PIN\ (PBKDF2,\ {0}\ rounds,\ peppered\ from\ the\ OS\ store);\ {1}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys.\ Wrong\ tries\ so\ far\:\ {2}=A vault opened by the PIN (PBKDF2, {0} rounds, peppered from the OS store); {1} wrong tries in a row erase the keys. Wrong tries so far: {2} +Active=Active +After\ {0}s\ idle\ or\ {1}s\ in\ the\ background=After {0}s idle or {1}s in the background +AppShield\ engine\ {0}\:\ {1}=AppShield engine {0}: {1} +Available;\ turn\ it\ on\ in\ Settings=Available; turn it on in Settings +Biometric\ copy\ simulated\:\ a\ file\ gated\ by\ the\ simulator's\ Biometric\ Simulation\ menu=Biometric copy simulated: a file gated by the simulator's Biometric Simulation menu +Can\ read\ the\ screen\:\ {0}=Can read the screen: {0} +Cash\ withdrawal=Cash withdrawal +Cloud\ build=Cloud build +Copied\ numbers\ are\ wiped\ after\ {0}s=Copied numbers are wiped after {0}s +Declared\ {0};\ applied\ by\ the\ build\ server=Declared {0}; applied by the build server +Deposit=Deposit +Development\ server\ {0}\ is\ exempt\:\ plain\ HTTP\ to\ this\ machine\ only=Development server {0} is exempt: plain HTTP to this machine only +Enter\ an\ amount,\ like\ {0}.=Enter an amount, like {0}. +Every\ entry\ balances\ and\ every\ balance\ matches\ its\ postings.=Every entry balances and every balance matches its postings. +Every\ request\ is\ https;\ http\ is\ refused\ ({0}\ refused)=Every request is https; http is refused ({0} refused) +Keychain\ item\ with\ biometric\ access\ control\ (current\ set)\:\ released\ only\ after\ Face\ ID\ or\ Touch\ ID=Keychain item with biometric access control (current set): released only after Face ID or Touch ID +Keystore\ key\ that\ requires\ user\ authentication\:\ released\ only\ after\ a\ strong\ biometric=Keystore key that requires user authentication: released only after a strong biometric +Level\ {0},\ mapping\ {1}=Level {0}, mapping {1} +Limit=Limit +Market=Market +My\ accounts=My accounts +No\ such\ account=No such account +None\ enrolled\:\ the\ PIN\ or\ the\ password\ unlocks\ instead=None enrolled: the PIN or the password unlocks instead +Not\ in\ simulator=Not in simulator +Not\ set\:\ Settings,\ Unlock\ PIN=Not set: Settings, Unlock PIN +On\ start,\ on\ return\ and\ when\ idle\:\ PIN=On start, on return and when idle: PIN +On\ start,\ on\ return\ and\ when\ idle\:\ biometric=On start, on return and when idle: biometric +On\ start,\ on\ return\ and\ when\ idle\:\ biometric,\ then\ PIN=On start, on return and when idle: biometric, then PIN +On\ start,\ on\ return\ and\ when\ idle\:\ the\ password=On start, on return and when idle: the password +Only\ {0}=Only {0} +Opened\ only\ by\ the\ unlock\ PIN=Opened only by the unlock PIN +Opened\ without\ asking\:\ set\ up\ a\ biometric\ or\ a\ PIN=Opened without asking: set up a biometric or a PIN +P-256,\ registered\ as\ {0};\ signs\ every\ payment=P-256, registered as {0}; signs every payment +Released\ by\ the\ platform\ after\ a\ biometric=Released by the platform after a biometric +Sealed\ under\ the\ app\ key,\ which\ opens\ without\ asking\ until\ a\ biometric\ or\ PIN\ is\ set\ up=Sealed under the app key, which opens without asking until a biometric or PIN is set up +Sealed\ under\ the\ app\ key\:\ unreadable\ until\ the\ lock\ is\ opened=Sealed under the app key: unreadable until the lock is opened +The\ audit\ chain\ is\ intact.=The audit chain is intact. +The\ PIN\ could\ not\ be\ removed.=The PIN could not be removed. +This\ device\ looks\ modified\:\ {0}.\ The\ bank\ will\ not\ move\ money\ from\ it.=This device looks modified: {0}. The bank will not move money from it. +This\ platform\ has\ no\ biometric-bound\ store\:\ set\ up\ a\ PIN=This platform has no biometric-bound store: set up a PIN +Try\ again=Try again +Unlock\ with\ a\ biometric\ is\ on=Unlock with a biometric is on +Warning=Warning +android.playIntegrity\={0}=android.playIntegrity={0} +ios.blockScreenshotsOnEnterBackground\={0}=ios.blockScreenshotsOnEnterBackground={0} +ios.detectJailbreak\={0}=ios.detectJailbreak={0} +{0}\ hosts\ pinned={0} hosts pinned +{0}\ hosts\ pinned,\ {1}\ mismatches\ refused={0} hosts pinned, {1} mismatches refused +{0}\ hosts,\ version\ {1}={0} hosts, version {1} +{0}\ hosts,\ version\ {1}\ (refresh\ due)={0} hosts, version {1} (refresh due) +error.price-outside-collar=The price must be within {0}% of the index price +error.no-such-entry=No such journal entry +error.entry-cannot-reversed=That entry cannot be reversed +error.entry-already-reversed=That entry was already reversed +error.period-closed=That date is in a closed period +error.period-end-past-last-close=A period must end in the past, after the last one closed +error.four-eyes-second-administrator=A second administrator must approve this +error.approval-not-pending=That request is no longer waiting for approval +error.staff-only=Staff only +error.enter-password-confirm=Enter your password to confirm +error.not-number=That is not a number +error.name-entity=Name the entity to trace +error.format-csv-json=The format is csv or json +Opening=Opening +Closing=Closing +Posted.=Posted. +Above\ the\ approval\ threshold,\ a\ second\ administrator\ must\ approve\ it.=Above the approval threshold, a second administrator must approve it. +Only\ the\ latest\ {0}\ postings\ are\ listed;\ the\ totals\ and\ balances\ cover\ them\ all.=Only the latest {0} postings are listed; the totals and balances cover them all. +Receipt=Receipt +Completed=Completed +Fee=Fee +Category=Category +Show=Show +Columns=Columns +Card=Card +Other=Other +Card\ details=Card details +Virtual\ debit=Virtual debit +Expires=Expires +Cardholder=Cardholder +{0}\ used\ today,\ {1}\ left={0} used today, {1} left +Recent\ card\ payments=Recent card payments +This\ card\ has\ not\ been\ used\ yet.=This card has not been used yet. +Account\ ID=Account ID +No\ movements\ in\ this\ period.=No movements in this period. +Card\ ID=Card ID +Entry\ id=Entry id +Currency=Currency +Books=Books +All\ accounts=All accounts +As\ of=As of +File=File +Made\ by=Made by +Generated=Generated +Print=Print +Printing\ is\ not\ available\ on\ this\ device.=Printing is not available on this device. +Could\ not\ print\:\ {0}=Could not print: {0} +Sent\ to\ the\ printer.=Sent to the printer. +Page\ {0}\ of\ {1}=Page {0} of {1} +{0}\ (continued)={0} (continued) +Device\ key\ ID=Device key ID +Passkey\ ID=Passkey ID +Copy\ {0}=Copy {0} +Copied=Copied +Or\ sign\ in\ with\ your\ e-mail\ and\ password.\ An\ account\ with\ a\ passkey\ takes\ its\ password\ only\ with\ a\ recovery\ code.=Or sign in with your e-mail and password. An account with a passkey takes its password only with a recovery code. +This\ account\ signs\ in\ with\ its\ passkey.\ Use\ the\ passkey,\ or\ ask\ the\ bank\ to\ help\ you\ recover\ the\ account.=This account signs in with its passkey. Use the passkey, or ask the bank to help you recover the account. +This\ account\ signs\ in\ with\ its\ passkey.\ Without\ it,\ enter\ one\ of\ your\ recovery\ codes.=This account signs in with its passkey. Without it, enter one of your recovery codes. +Custody=Custody +Held\ on\ chain\ {0}=Held on chain {0} +Owed\ to\ customers\ {0}=Owed to customers {0} +Hot\ wallet=Hot wallet +In\ transit=In transit +Withdrawals\ not\ yet\ confirmed=Withdrawals not yet confirmed +Receive\ crypto=Receive crypto +Chain\ {0}=Chain {0} +Your\ {0}\ address=Your {0} address +Credited\ after\ {0}\ confirmations,\ usually\ a\ few\ minutes.=Credited after {0} confirmations, usually a few minutes. +On\ the\ chain=On the chain +Nothing\ has\ crossed\ the\ chain\ yet.=Nothing has crossed the chain yet. +To\ {0}=To {0} +Final=Final +Failed=Failed +Confirming=Confirming +Sending=Sending +Set\ up\ a\ second\ factor=Set up a second factor +Open\ Settings=Open Settings +error.too-many-new-addresses=You have added as many addresses as a day allows; try again tomorrow +error.second-factor-add-device=Set up an authenticator app or a passkey, then sign in with it to add a device +error.too-many-new-payees=You have added as many payees as a day allows; try again tomorrow diff --git a/scripts/bastion/common/src/main/l10n/Bundle_es.properties b/scripts/bastion/common/src/main/l10n/Bundle_es.properties new file mode 100644 index 00000000000..b60644fb266 --- /dev/null +++ b/scripts/bastion/common/src/main/l10n/Bundle_es.properties @@ -0,0 +1,745 @@ +# Bastion in Spanish. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +# These translations were generated automatically as a sample and have not +# been reviewed by a native speaker. Review them before shipping. + +@rtl=false +language.name=Espa\u00f1ol +Account=Cuenta +Frozen=Congelada +Move\ money=Mover dinero +Send=Enviar +Add\ money=A\u00f1adir dinero +Withdraw=Retirar +Order\ a\ card=Pedir una tarjeta +No\ card=Sin tarjeta +History=Historial +Nothing\ has\ moved\ in\ this\ account\ yet.=A\u00fan no ha habido movimientos en esta cuenta. +Money\ in=Entradas +Money\ out=Salidas +Amount=Importe +Add=A\u00f1adir +Withdraw\ cash=Retirar efectivo +Cancel=Cancelar +Not\ done=No realizado +Administration=Administraci\u00f3n +Administrators\ only.=Solo para administradores. +Recomputed\ from\ the\ stored\ rows\ each\ time\ you\ ask.=Se recalcula a partir de las filas almacenadas cada vez que lo pides. +Verify\ the\ audit\ log=Verificar el registro de auditor\u00eda +Verify\ the\ ledger=Verificar el libro mayor +Audit\ log=Registro de auditor\u00eda +Ledger=Libro mayor +Intact=Intacto +Problems\ found=Problemas encontrados +Cards=Tarjetas +No\ cards\ yet.\ Order\ one\ from\ a\ checking\ account.=A\u00fan no tienes tarjetas. Pide una desde una cuenta corriente. +Bastion=Bastion +Spends\ up\ to\ {0}\ a\ day=Gasta hasta {0} al d\u00eda +Unfreeze=Descongelar +Freeze=Congelar +Not\ changed=No modificado +Daily\ limit=L\u00edmite diario +Save=Guardar +Sign\ in\ first=Inicia sesi\u00f3n primero +Password=Contrase\u00f1a +Continue=Continuar +Confirm\ it\ is\ you=Confirma que eres t\u00fa +Sign\ with\ this\ device=Firmar con este dispositivo +Payments\ leaving\ your\ accounts\ are\ signed\ by\ a\ key\ that\ never\ leaves\ this\ phone.\ Register\ this\ device\ to\ continue.=Los pagos que salen de tus cuentas se firman con una clave que nunca sale de este tel\u00e9fono. Registra este dispositivo para continuar. +Register=Registrar +Not\ now=Ahora no +Exchange=Cambio +Markets=Mercados +No\ markets\ are\ open.=No hay mercados abiertos. +Index\ {0}=\u00cdndice {0} +Your\ trades=Tus operaciones +You\ have\ not\ traded\ yet.=A\u00fan no has operado. +Welcome=Bienvenido +Hello,\ {0}=Hola, {0} +Total\ balance=Saldo total +Not\ counted,\ no\ dollar\ price\:\ {0}=No contabilizado, sin precio en d\u00f3lares: {0} +Pay\ someone=Pagar a alguien +Trade=Operar +Accounts=Cuentas +You\ have\ no\ accounts\ yet.=A\u00fan no tienes cuentas. +Open\ an\ account=Abrir una cuenta +Checking=Corriente +Savings=Ahorro +Wallet=Monedero +This\ device\ cannot\ sign\ payments\ yet=Este dispositivo a\u00fan no puede firmar pagos +Payments\ to\ other\ people\ and\ crypto\ withdrawals\ need\ a\ key\ that\ lives\ only\ on\ this\ phone.=Los pagos a otras personas y las retiradas de criptomonedas necesitan una clave que solo existe en este tel\u00e9fono. +Register\ this\ device=Registrar este dispositivo +{0}\ wallet=Monedero de {0} +{0}\ checking=Cuenta corriente en {0} +{0}\ savings=Ahorro en {0} +Name\ (optional)=Nombre (opcional) +Open=Abrir +Not\ opened=No abierta +Locked=Bloqueado +Bastion\ is\ locked=Bastion est\u00e1 bloqueado +Unlock=Desbloquear +Sign\ in\ again=Inicia sesi\u00f3n de nuevo +Not\ recognised.\ Try\ again.=No se ha reconocido. Int\u00e9ntalo de nuevo. +Last\ price=\u00daltimo precio +Order\ book=Libro de \u00f3rdenes +Place\ an\ order=Hacer una orden +Your\ open\ orders=Tus \u00f3rdenes abiertas +Bid\ {0}\ -\ Ask\ {1}=Compra {0} - Venta {1} +The\ book\ is\ empty.=El libro est\u00e1 vac\u00edo. +Buy=Comprar +Sell=Vender +at\ {0},\ {1}\ filled=a {0}, {1} ejecutado +No\ open\ orders\ in\ this\ market.=No hay \u00f3rdenes abiertas en este mercado. +Price\ in\ {0}=Precio en {0} +Amount\ of\ {0}=Cantidad de {0} +Spend,\ in\ {0}=Gasto, en {0} +In\ steps\ of\ {0}=En m\u00faltiplos de {0} +Place\ order=Hacer orden +Confirm\ order=Confirmar orden +Place=Hacer +Order\ {0}=Orden {0} +Enter\ how\ much\ to\ spend.=Introduce cu\u00e1nto quieres gastar. +Enter\ an\ amount\ in\ steps\ of\ {0}.=Introduce una cantidad en m\u00faltiplos de {0}. +Enter\ a\ price.=Introduce un precio. +Buy\ {0}\ at\ the\ best\ prices,\ spending\ up\ to\ {1}?=\u00bfComprar {0} a los mejores precios, gastando hasta {1}? +Sell\ {0}\ at\ the\ best\ prices?=\u00bfVender {0} a los mejores precios? +Buy\ {0}\ at\ {1}\ (about\ {2})?=\u00bfComprar {0} a {1} (unos {2})? +Sell\ {0}\ at\ {1}\ (about\ {2})?=\u00bfVender {0} a {1} (unos {2})? +filled=ejecutada +on\ the\ book=en el libro +ended,\ the\ rest\ unfilled=finalizada, el resto sin ejecutar +Bought\ {0}=Comprado {0} +Sold\ {0}=Vendido {0} +at\ {0},\ fee\ {1}=a {0}, comisi\u00f3n {1} +Payees=Beneficiarios +Send\ crypto=Enviar cripto +Security\ Center=Centro de seguridad +Settings=Ajustes +Sign\ out=Cerrar sesi\u00f3n +Bank\ with\ Bastion=Opera con Bastion +Takes\ two\ minutes.\ You\ will\ set\ up\ a\ second\ factor\ and\ this\ phone's\ signing\ key\ as\ you\ go.=Lleva dos minutos. Configurar\u00e1s un segundo factor y la clave de firma de este tel\u00e9fono por el camino. +Your\ name=Tu nombre +E-mail=Correo electr\u00f3nico +Password\ again=Repite la contrase\u00f1a +Create\ account=Crear cuenta +At\ least\ {0}\ characters=Al menos {0} caracteres +Both\ passwords\ match=Las dos contrase\u00f1as coinciden +Not\ your\ e-mail\ address=No es tu correo electr\u00f3nico +A\ few\ unrelated\ words\ make\ a\ password\ that\ is\ long\ and\ easy\ to\ remember.=Unas pocas palabras sin relaci\u00f3n forman una contrase\u00f1a larga y f\u00e1cil de recordar. +Sign\ in\ to\ continue.=Inicia sesi\u00f3n para continuar. +Authenticator\ app=App de autenticaci\u00f3n +Add\ a\ second\ factor=A\u00f1adir un segundo factor +Scan\ this\ with\ an\ authenticator\ app.\ From\ now\ on\ a\ sign-in\ with\ your\ password\ also\ asks\ for\ the\ code\ it\ shows.=Escanea esto con una app de autenticaci\u00f3n. A partir de ahora, iniciar sesi\u00f3n con tu contrase\u00f1a tambi\u00e9n pedir\u00e1 el c\u00f3digo que muestra. +Or\ enter\ this\ key=O introduce esta clave +Copy\ key=Copiar clave +Copy\ codes=Copiar c\u00f3digos +Copied.\ The\ clipboard\ clears\ itself\ in\ a\ minute.=Copiado. El portapapeles se vac\u00eda solo en un minuto. +Open\ authenticator=Abrir la app de autenticaci\u00f3n +Six-digit\ code=C\u00f3digo de seis d\u00edgitos +Turn\ on=Activar +New\ recovery\ codes=Nuevos c\u00f3digos de recuperaci\u00f3n +Recovery\ codes=C\u00f3digos de recuperaci\u00f3n +Save\ your\ recovery\ codes=Guarda tus c\u00f3digos de recuperaci\u00f3n +Each\ signs\ you\ in\ once\ if\ you\ lose\ your\ phone.\ Keep\ them\ somewhere\ safe\ and\ offline.\ They\ will\ not\ be\ shown\ again.=Cada uno te permite iniciar sesi\u00f3n una vez si pierdes el tel\u00e9fono. Gu\u00e1rdalos en un lugar seguro y sin conexi\u00f3n. No se volver\u00e1n a mostrar. +I\ have\ saved\ them=Ya los he guardado +Passkey=Llave de acceso +Sign\ in\ with\ a\ passkey=Iniciar sesi\u00f3n con una llave de acceso +A\ passkey\ is\ kept\ by\ your\ phone\ and\ unlocked\ by\ your\ face,\ fingerprint\ or\ screen\ lock.\ It\ cannot\ be\ phished,\ and\ replaces\ both\ the\ password\ and\ the\ code.=Una llave de acceso la guarda tu tel\u00e9fono y se desbloquea con tu rostro, tu huella o el bloqueo de pantalla. No se puede suplantar con phishing y sustituye tanto a la contrase\u00f1a como al c\u00f3digo. +Create\ a\ passkey=Crear una llave de acceso +Bastion\ on\ {0}=Bastion en {0} +Unlock\ with\ your\ face\ or\ fingerprint?=\u00bfDesbloquear con tu rostro o tu huella? +Bastion\ locks\ itself\ when\ you\ leave\ it.\ With\ this\ on,\ a\ biometric\ unlocks\ it;\ with\ it\ off,\ you\ sign\ in\ again.\ The\ device\ passcode\ is\ never\ accepted.=Bastion se bloquea solo cuando sales de la app. Con esta opci\u00f3n activada, la biometr\u00eda lo desbloquea; con ella desactivada, inicias sesi\u00f3n de nuevo. El c\u00f3digo del dispositivo nunca se acepta. +Use\ biometrics=Usar biometr\u00eda +This\ device=Este dispositivo +Let\ this\ phone\ sign\ your\ payments=Permite que este tel\u00e9fono firme tus pagos +Bastion\ makes\ a\ key\ on\ this\ phone\ and\ gives\ the\ bank\ only\ its\ public\ half.\ Payments\ to\ other\ people\ and\ crypto\ withdrawals\ must\ carry\ its\ signature,\ so\ a\ stolen\ password\ alone\ cannot\ move\ your\ money.=Bastion crea una clave en este tel\u00e9fono y entrega al banco solo su mitad p\u00fablica. Los pagos a otras personas y las retiradas de criptomonedas deben llevar su firma, as\u00ed que una contrase\u00f1a robada no basta para mover tu dinero. +Later=M\u00e1s tarde +Add\ a\ payee=A\u00f1adir un beneficiario +No\ payees\ yet.\ Add\ one\ by\ the\ e-mail\ address\ they\ bank\ with.=A\u00fan no tienes beneficiarios. A\u00f1ade uno con el correo electr\u00f3nico con el que opera en el banco. +New\ payee\:\ limited\ until\ {0}=Beneficiario nuevo: limitado hasta {0} +Cooling\ off=En periodo de espera +Ready=Listo +Their\ e-mail=Su correo electr\u00f3nico +Name\ to\ show=Nombre para mostrar +Payee\ added.\ It\ is\ limited\ while\ it\ cools\ off.=Beneficiario a\u00f1adido. Est\u00e1 limitado mientras dura el periodo de espera. +Not\ added=No a\u00f1adido +Rename=Renombrar +Remove=Quitar +Remove\ payee=Quitar beneficiario +Remove\ {0}?\ Adding\ them\ again\ starts\ a\ new\ cooling-off.=\u00bfQuitar a {0}? Si lo a\u00f1ades de nuevo, empieza un nuevo periodo de espera. +Done=Hecho +Sent=Enviado +Reference=Referencia +When=Cu\u00e1ndo +Note=Nota +What\ protects\ your\ account\ on\ this\ device.\ Nothing\ here\ is\ decoration\:\ each\ line\ is\ read\ from\ the\ app\ as\ it\ runs.=Lo que protege tu cuenta en este dispositivo. Nada aqu\u00ed es decoraci\u00f3n: cada l\u00ednea se lee de la app mientras se ejecuta. +Back=Atr\u00e1s +Unlock\ with\ fingerprint\ or\ face=Desbloquear con huella o rostro +This\ device\ has\ no\ biometric\ enrolled.\ The\ lock\ asks\ for\ your\ password.=Este dispositivo no tiene biometr\u00eda registrada. El bloqueo pide tu contrase\u00f1a. +Off=Desactivado +Sign-in=Inicio de sesi\u00f3n +{0}\ recovery\ codes\ left=Quedan {0} c\u00f3digos de recuperaci\u00f3n +Adds\ a\ six-digit\ code\ to\ every\ password\ sign-in.=A\u00f1ade un c\u00f3digo de seis d\u00edgitos a cada inicio de sesi\u00f3n con contrase\u00f1a. +On=Activado +Payment\ signing\ key=Clave de firma de pagos +Payments\ from\ this\ phone\ are\ signed\ by\ a\ key\ that\ never\ leaves\ it.=Los pagos desde este tel\u00e9fono se firman con una clave que nunca sale de \u00e9l. +Register\ this\ device\ to\ sign\ payments.=Registra este dispositivo para firmar pagos. +Sign\ in=Iniciar sesi\u00f3n +Your\ bank,\ locked\ down.=Tu banco, blindado. +Verification=Verificaci\u00f3n +Enter\ your\ code=Introduce tu c\u00f3digo +Open\ your\ authenticator\ app\ and\ enter\ the\ six-digit\ code,\ or\ one\ of\ your\ recovery\ codes.=Abre tu app de autenticaci\u00f3n e introduce el c\u00f3digo de seis d\u00edgitos, o uno de tus c\u00f3digos de recuperaci\u00f3n. +Code=C\u00f3digo +You\ have\ no\ account\ that\ can\ send\ money.=No tienes ninguna cuenta desde la que se pueda enviar dinero. +Add\ a\ payee\ first,\ under\ Payees.=A\u00f1ade primero un beneficiario, en Beneficiarios. +Note\ (optional)=Nota (opcional) +Review=Revisar +From=De +To=Para +Choose\ who\ to\ pay.=Elige a qui\u00e9n pagar. +That\ is\ more\ than\ this\ account\ holds.=Es m\u00e1s de lo que tiene esta cuenta. +Choose\ two\ different\ accounts.=Elige dos cuentas distintas. +Confirm=Confirmar +Send\ {0}\ from\ {1}\ to\ {2}?=\u00bfEnviar {0} de {1} a {2}? +Loading...=Cargando... +Open\ a\ crypto\ wallet\ to\ send\ coins\ from.=Abre un monedero cripto desde el que enviar monedas. +Your\ addresses=Tus direcciones +Add\ an\ address=A\u00f1adir una direcci\u00f3n +No\ addresses\ yet.\ Coins\ can\ be\ sent\ only\ to\ an\ address\ on\ this\ list.=A\u00fan no hay direcciones. Las monedas solo se pueden enviar a una direcci\u00f3n de esta lista. +Usable\ from\ {0}=Utilizable desde {0} +Waiting=En espera +No\ {0}\ address\ is\ ready\ yet.=A\u00fan no hay ninguna direcci\u00f3n de {0} lista. +Choose\ an\ address.=Elige una direcci\u00f3n. +Enter\ an\ amount.=Introduce un importe. +That\ is\ more\ than\ this\ wallet\ holds.=Es m\u00e1s de lo que tiene este monedero. +Send\ {0}\ to\ {1}?\ This\ cannot\ be\ undone.=\u00bfEnviar {0} a {1}? No se puede deshacer. +Address=Direcci\u00f3n +Label,\ like\ My\ hardware\ wallet=Etiqueta, como Mi monedero f\u00edsico +Address\ added=Direcci\u00f3n a\u00f1adida +For\ your\ safety\ it\ can\ be\ used\ from\ {0}.=Por tu seguridad, se podr\u00e1 usar a partir de {0}. +Remove\ address=Quitar direcci\u00f3n +Remove\ {0}?\ Adding\ it\ again\ starts\ a\ new\ wait.=\u00bfQuitar {0}? Si la a\u00f1ades de nuevo, empieza una nueva espera. +Not\ removed=No quitado +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=No se pudo contactar con el servidor. Comprueba tu conexi\u00f3n. +Something\ went\ wrong\ ({0}).\ Try\ again.=Algo sali\u00f3 mal ({0}). Int\u00e9ntalo de nuevo. +The\ server\ changed.\ Sign\ in\ again.=El servidor ha cambiado. Inicia sesi\u00f3n de nuevo. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=Demasiados intentos. Espera un minuto e int\u00e9ntalo de nuevo. +This\ account\ is\ locked\ for\ a\ while\ after\ too\ many\ attempts.=Esta cuenta queda bloqueada un tiempo tras demasiados intentos. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=Ese correo electr\u00f3nico y esa contrase\u00f1a no corresponden a ninguna cuenta. +That\ code\ is\ not\ right.\ Codes\ change\ every\ 30\ seconds.=Ese c\u00f3digo no es correcto. Los c\u00f3digos cambian cada 30 segundos. +Passkey\ sign-in\ is\ not\ available\ right\ now.=El inicio de sesi\u00f3n con llave de acceso no est\u00e1 disponible ahora. +That\ passkey\ was\ not\ accepted.=No se acept\u00f3 esa llave de acceso. +Passkey\ sign-in\ was\ cancelled.=Se cancel\u00f3 el inicio de sesi\u00f3n con llave de acceso. +Signing\ in\ failed.\ Try\ again.=No se pudo iniciar sesi\u00f3n. Int\u00e9ntalo de nuevo. +Sign\ in\ again\ to\ add\ a\ passkey.=Inicia sesi\u00f3n de nuevo para a\u00f1adir una llave de acceso. +Passkeys\ are\ not\ available\ right\ now.=Las llaves de acceso no est\u00e1n disponibles ahora. +That\ passkey\ was\ not\ saved.=No se guard\u00f3 esa llave de acceso. +Creating\ the\ passkey\ was\ cancelled.=Se cancel\u00f3 la creaci\u00f3n de la llave de acceso. +This\ device\ has\ no\ secure\ storage\ for\ the\ key.=Este dispositivo no tiene almacenamiento seguro para la clave. +Unlock\ Bastion=Desbloquear Bastion +Unlock\ Bastion\ and\ the\ keys\ that\ sign\ your\ payments=Desbloquea Bastion y las claves que firman tus pagos +Network=Red +Keys\ and\ sign-in=Claves e inicio de sesi\u00f3n +Build\ hardening=Refuerzo de la compilaci\u00f3n +No\ cleartext=Sin texto plano +Development\ server\ =Servidor de desarrollo +\ is\ exempt\:\ plain\ HTTP\ to\ this\ machine\ only=\ est\u00e1 exento: HTTP sin cifrar solo hacia esta m\u00e1quina +Every\ request\ is\ https;\ http\ is\ refused=Todas las solicitudes son https; se rechaza http +Host\ allow-list=Lista de hosts permitidos +Exempt\ while\ the\ development\ server\ is\ in\ use=Exenta mientras se usa el servidor de desarrollo +Only\ =Solo +Over-the-air\ pins=Pines por aire +Published\ by\ the\ attestation\ service;\ none\ in\ this\ build=Publicados por el servicio de atestaci\u00f3n; ninguno en esta compilaci\u00f3n +\ hosts,\ version\ =\ hosts, versi\u00f3n +\ (refresh\ due)=\ (actualizaci\u00f3n pendiente) +App\ attestation\ token=Token de atestaci\u00f3n de la app +AppShield\ engine\ =Motor AppShield +AppShield\ needs\ the\ enterprise\ engine\ in\ a\ cloud\ build;\ inert\ here=AppShield necesita el motor enterprise en una compilaci\u00f3n en la nube; aqu\u00ed est\u00e1 inactivo +Device\ signing\ key=Clave de firma del dispositivo +P-256,\ registered\ as\ =P-256, registrada como +;\ signs\ every\ payment=; firma cada pago +Not\ registered\:\ payments\ that\ need\ a\ signature\ are\ refused=No registrada: se rechazan los pagos que necesitan firma +Idempotency\ keys=Idempotency-Key +Each\ money\ action\ carries\ one,\ so\ a\ retry\ never\ pays\ twice=Cada operaci\u00f3n de dinero lleva una, as\u00ed que un reintento nunca paga dos veces +Second\ factor\ and\ passkeys=Segundo factor y llaves de acceso +Authenticator\ codes\ and\ passkeys=C\u00f3digos de autenticaci\u00f3n y llaves de acceso +Authenticator\ codes\ (no\ passkeys\ on\ this\ platform)=C\u00f3digos de autenticaci\u00f3n (sin llaves de acceso en esta plataforma) +Biometric\ lock=Bloqueo biom\u00e9trico +On\ start,\ on\ return\ and\ when\ idle=Al iniciar, al volver y en reposo +No\ biometrics\ enrolled\:\ the\ lock\ asks\ for\ the\ password\ instead=Sin biometr\u00eda registrada: el bloqueo pide la contrase\u00f1a +Auto-lock=Bloqueo autom\u00e1tico +After\ =Tras +s\ idle\ or\ =s en reposo o +s\ in\ the\ background=s en segundo plano +Platform\ attestation=Atestaci\u00f3n de la plataforma +Play\ Integrity=Play Integrity +App\ Attest=App Attest +Play\ Integrity\ /\ App\ Attest\ need\ a\ device\ build=Play Integrity / App Attest necesitan una compilaci\u00f3n para dispositivo +Accessibility\ services=Servicios de accesibilidad +Can\ read\ the\ screen\:\ =Pueden leer la pantalla: +None\ that\ can\ read\ the\ screen=Ninguno que pueda leer la pantalla +Tapjacking=Tapjacking +Touches\ through\ overlays\ are\ dropped=Se descartan los toques a trav\u00e9s de superposiciones +Policy\ set;\ only\ Android\ has\ overlays=Pol\u00edtica definida; solo Android tiene superposiciones +Screenshot\ blocking=Bloqueo de capturas de pantalla +Set\ on\ sensitive\ screens;\ the\ simulator\ cannot\ block\ captures=Activado en las pantallas sensibles; el simulador no puede bloquear capturas +Sensitive\ screens,\ and\ the\ app\ switcher\ card=Pantallas sensibles y la tarjeta del selector de apps +Sensitive\ screens\ and\ the\ recent-apps\ card=Pantallas sensibles y la tarjeta de apps recientes +Clipboard\ clearing=Vaciado del portapapeles +Copied\ numbers\ are\ wiped\ after\ =Los n\u00fameros copiados se borran tras +Code\ hardening=Refuerzo del c\u00f3digo +Level\ =Nivel +,\ mapping\ =, mapeo +Declared\ =Declarado +;\ applied\ by\ the\ build\ server=; aplicado por el servidor de compilaci\u00f3n +Jailbreak\ exit\ (iOS)=Salida por jailbreak (iOS) +App\ switcher\ blanking\ (iOS)=Ocultaci\u00f3n en el selector de apps (iOS) +Play\ Integrity\ (Android)=Play Integrity (Android) +Certificate\ pinning=Fijaci\u00f3n de certificados +Not\ applied\ to\ the\ development\ server\ (it\ has\ no\ certificate)=No se aplica al servidor de desarrollo (no tiene certificado) +Placeholder\ pins\:\ put\ the\ server's\ SPKI\ digests\ in\ AppConfig.PINS=Pines de marcador: pon los res\u00famenes SPKI del servidor en AppConfig.PINS +\ hosts\ pinned=\ hosts fijados +\ mismatches\ refused=\ discrepancias rechazadas +Token\ and\ key\ storage=Almacenamiento de tokens y claves +Hardware-backed\ keystore=Almac\u00e9n de claves respaldado por hardware +OS\ keystore=Almac\u00e9n de claves del sistema +The\ simulator\ keeps\ them\ in\ a\ plain\ file=El simulador los guarda en un archivo sin cifrar +No\ keystore\ on\ this\ device\:\ tokens\ are\ not\ kept\ between\ runs=Sin almac\u00e9n de claves en este dispositivo: los tokens no se conservan entre ejecuciones +Root\ and\ jailbreak\ checks=Comprobaciones de root y jailbreak +Run\ on\ a\ phone;\ the\ simulator\ reports\ nothing=Ejecuta en un tel\u00e9fono; el simulador no informa de nada +This\ device\ looks\ modified\:\ =Este dispositivo parece modificado: +No\ signs\ of\ root\ or\ jailbreak=Sin indicios de root ni jailbreak +OK=OK +Last\ used\ {0}=\u00daltimo uso {0} +Add\ a\ passkey=A\u00f1adir una llave de acceso +This\ device\ cannot\ create\ passkeys.=Este dispositivo no puede crear llaves de acceso. +Remove\ passkey=Quitar llave de acceso +Remove\ {0}?\ It\ will\ no\ longer\ sign\ in\ to\ this\ account.=\u00bfQuitar {0}? Ya no podr\u00e1 iniciar sesi\u00f3n en esta cuenta. +Code\ from\ your\ app=C\u00f3digo de tu app +Authenticator\ code=C\u00f3digo de autenticaci\u00f3n +Please\ sign\ in\ again.=Vuelve a iniciar sesi\u00f3n. +Your\ account\ is\ not\ allowed\ to\ do\ that.=Tu cuenta no tiene permiso para hacer eso. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=Demasiados intentos. Espera un momento e int\u00e9ntalo de nuevo. +Unlock\ Bastion\ first.=Desbloquea Bastion primero. +PIN=PIN +Unlock\ with\ PIN=Desbloquear con PIN +Not\ recognised.\ Try\ again,\ or\ use\ your\ PIN.=No se ha reconocido. Int\u00e9ntalo de nuevo o usa tu PIN. +Enter\ your\ PIN\ of\ at\ least\ {0}\ digits.=Introduce tu PIN de al menos {0} d\u00edgitos. +Wrong\ PIN.\ One\ try\ left\ before\ the\ keys\ on\ this\ device\ are\ erased.=PIN incorrecto. Te queda un intento antes de que se borren las claves de este dispositivo. +Wrong\ PIN.\ {0}\ tries\ left.=PIN incorrecto. Quedan {0} intentos. +Too\ many\ wrong\ PINs.\ The\ keys\ on\ this\ device\ were\ erased.=Demasiados PIN incorrectos. Se borraron las claves de este dispositivo. +Unlock\ PIN=PIN de desbloqueo +Choose\ an\ unlock\ PIN=Elige un PIN de desbloqueo +Set\ PIN=Definir PIN +Change\ PIN=Cambiar PIN +This\ device\ has\ no\ biometric\ enrolled.\ Set\ an\ unlock\ PIN\ instead.=Este dispositivo no tiene biometr\u00eda registrada. Define un PIN de desbloqueo en su lugar. +Without\ a\ biometric\ or\ a\ PIN\ the\ lock\ asks\ for\ your\ password.=Sin biometr\u00eda ni PIN, el bloqueo pide tu contrase\u00f1a. +Remove\ PIN=Quitar PIN +Let\ your\ biometric\ unlock\ Bastion=Permite que tu biometr\u00eda desbloquee Bastion +New\ PIN=PIN nuevo +The\ same\ PIN\ again=El mismo PIN otra vez +The\ two\ PINs\ are\ not\ the\ same.=Los dos PIN no coinciden. +Choose\ 6\ to\ 12\ digits\ that\ are\ not\ a\ simple\ run.=Elige de 6 a 12 d\u00edgitos que no sean una secuencia simple. +No\ biometric\ or\ PIN\ is\ set\ up\ on\ this\ device,\ so\ the\ way\ back\ in\ is\ your\ password.=No hay biometr\u00eda ni PIN configurados en este dispositivo, as\u00ed que la forma de volver a entrar es tu contrase\u00f1a. +Signing\ in\ again\ sets\ this\ device\ up\ from\ the\ start\:\ its\ payment\ key,\ biometric\ and\ PIN\ are\ removed.=Iniciar sesi\u00f3n de nuevo configura este dispositivo desde cero: se eliminan su clave de pagos, la biometr\u00eda y el PIN. +Bastion\ locks\ itself\ when\ you\ leave\ it.\ With\ this\ on,\ the\ phone\ keeps\ the\ key\ to\ your\ session\ and\ payments\ where\ only\ your\ biometric\ releases\ it.\ The\ device\ passcode\ is\ never\ accepted.=Bastion se bloquea solo cuando sales de la app. Con esta opci\u00f3n activada, el tel\u00e9fono guarda la clave de tu sesi\u00f3n y de tus pagos donde solo tu biometr\u00eda puede liberarla. El c\u00f3digo del dispositivo nunca se acepta. +For\ when\ a\ biometric\ is\ not\ available\ or\ you\ would\ rather\ not\ use\ one\:\ {0}\ to\ {1}\ digits,\ not\ your\ card\ PIN.\ {2}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys\ on\ this\ device.=Para cuando no hay biometr\u00eda disponible o prefieres no usarla: de {0} a {1} d\u00edgitos, no el PIN de tu tarjeta. {2} intentos err\u00f3neos seguidos borran las claves de este dispositivo. +Opens\ the\ lock\ when\ a\ biometric\ cannot.\ {0}\ wrong\ tries\ erase\ the\ keys\ on\ this\ device.=Abre el bloqueo cuando la biometr\u00eda no puede. {0} intentos err\u00f3neos borran las claves de este dispositivo. +Unlock\ first.=Desbloquea primero. +This\ device\ could\ not\ keep\ a\ biometric-bound\ key.=Este dispositivo no pudo guardar una clave ligada a la biometr\u00eda. +This\ device\ has\ no\ biometric-bound\ key\ store.=Este dispositivo no tiene almac\u00e9n de claves ligado a la biometr\u00eda. +Biometric\ setup\ was\ cancelled.=Se cancel\u00f3 la configuraci\u00f3n de la biometr\u00eda. +No\ PIN\ is\ set\ on\ this\ device.=No hay ning\u00fan PIN definido en este dispositivo. +The\ PIN\ vault\ is\ damaged.=La caja fuerte del PIN est\u00e1 da\u00f1ada. +The\ PIN\ vault\ could\ not\ be\ read.=No se pudo leer la caja fuerte del PIN. +The\ PIN\ could\ not\ be\ saved.=No se pudo guardar el PIN. +The\ old\ PIN\ could\ not\ be\ removed.=No se pudo quitar el PIN anterior. +This\ device\ has\ no\ secure\ storage\ for\ the\ PIN.=Este dispositivo no tiene almacenamiento seguro para el PIN. +App\ key=Clave de la app +Refresh\ token=Token de renovaci\u00f3n +App\ lock=Bloqueo de la app +Biometrics=Biometr\u00eda +A\ signed-off\ copy\ for\ an\ external\ auditor\:\ the\ file,\ who\ made\ it\ and\ when,\ and\ its\ SHA-256\ so\ it\ can\ be\ checked\ later.=Una copia firmada para un auditor externo: el archivo, qui\u00e9n lo gener\u00f3 y cu\u00e1ndo, y su SHA-256 para comprobarlo despu\u00e9s. +Access\ log=Registro de accesos +Action=Acci\u00f3n +Actor=Actor +Actual=Real +Adjustment=Ajuste +All\ entries=Todos los asientos +Amount\ (negative\ to\ debit)=Importe (negativo para cargar) +Approvals=Aprobaciones +Approve=Aprobar +Approved=Aprobado +As\ of\ {0}=A {0} +Asset=Activo +Assets=Activos +Assets\ \=\ liabilities\ +\ equity=Activos = pasivos + patrimonio +Audit=Auditor\u00eda +Audit\ chain=Cadena de auditor\u00eda +Audit\ trail=Rastro de auditor\u00eda +Audit\ trail\ of\ this\ entry=Rastro de auditor\u00eda de este asiento +Awaiting\ approval=Pendiente de aprobaci\u00f3n +Balance=Saldo +Balance\ after=Saldo despu\u00e9s +Balance\ sheet=Balance general +Balance,\ 30\ days=Saldo, 30 d\u00edas +Balanced=Cuadrado +Breaks=Descuadres +Broken\ at\ \#{0}=Rota en #{0} +CSV=CSV +Capital=Capital +Class=Clase +Close\ period=Cerrar periodo +Close\ the\ books\ through\ now=Cerrar los libros hasta ahora +Closed\ at=Cerrado el +Closed\ by=Cerrado por +Closed\ through=Cerrado hasta +Closing\ a\ period\ moves\ revenue\ and\ expense\ into\ retained\ earnings\ and\ locks\ every\ entry\ up\ to\ its\ date.=Cerrar un periodo traslada ingresos y gastos a resultados acumulados y bloquea todos los asientos hasta su fecha. +Closing\ balance=Saldo final +Closing\ entry=Asiento de cierre +Control=Control +Could\ not\ save\ the\ file.=No se pudo guardar el archivo. +Credit\ (Cr)=Haber (H) +Credit\ to\ your\ account=Abono en tu cuenta +Crypto\ withdrawal=Retiro de cripto +Date=Fecha +Debit\ (Dr)=Debe (D) +Debit\ from\ your\ account=Cargo en tu cuenta +Decide=Decidir +Description=Descripci\u00f3n +Difference=Diferencia +Dollar\ balance,\ 30\ days=Saldo en d\u00f3lares, 30 d\u00edas +Dollar\ volume,\ 30\ days=Volumen en d\u00f3lares, 30 d\u00edas +Each\ control\ account\ against\ the\ sum\ of\ its\ subledger.\ A\ difference\ is\ a\ break\ and\ is\ shown\ in\ red.=Cada cuenta de control frente a la suma de su mayor auxiliar. Una diferencia es un descuadre y se muestra en rojo. +Each\ event's\ hash\ covers\ its\ own\ fields\ and\ the\ hash\ before\ it,\ so\ changing\ any\ row\ breaks\ every\ link\ after\ it.=El hash de cada evento cubre sus campos y el hash anterior, as\u00ed que cambiar una fila rompe todos los enlaces posteriores. +Entries=Asientos +Entry=Asiento +Entry\ id,\ account\ id\ or\ e-mail=ID de asiento, de cuenta o correo +Equity=Patrimonio +Every\ control\ account\ agrees\ with\ its\ subledger.=Todas las cuentas de control cuadran con su auxiliar. +Every\ entry,\ newest\ first.\ Debits\ and\ credits\ of\ an\ entry\ sum\ to\ the\ same\ amount\ in\ each\ currency.\ Tap\ an\ entry\ for\ its\ legs.=Todos los asientos, el m\u00e1s reciente primero. El debe y el haber de un asiento suman lo mismo en cada moneda. Toca un asiento para ver sus partidas. +Every\ event\ that\ names\ the\ subject,\ oldest\ first.=Todos los eventos que nombran el sujeto, el m\u00e1s antiguo primero. +Every\ read\ of\ the\ books\ and\ the\ audit\ log\ by\ staff\ is\ itself\ an\ audit\ event.=Cada lectura de los libros y del registro de auditor\u00eda por el personal es a su vez un evento de auditor\u00eda. +Evidence=Evidencia +Expected=Esperado +Expenses=Gastos +General\ ledger=Libro mayor +Hash=Hash +Head=Cabeza +Head\ hash=Hash de cabeza +In\ dollars=En d\u00f3lares +Income\ statement=Estado de resultados +Initiated\ by=Iniciado por +Integrity\ checks=Controles de integridad +JSON=JSON +Journal=Diario +Journal\ entries=Asientos del diario +Journal\ entries\ a\ day=Asientos por d\u00eda +Journal\ entry\ {0}=Asiento {0} +Ledger\ entry=Asiento contable +Liabilities=Pasivos +Links\ checked=Enlaces comprobados +Made\ by\ {0},\ {1}=Hecho por {0}, {1} +Maker=Creador +Maker-checker\:\ an\ adjustment\ or\ a\ reversal\ raised\ by\ one\ administrator\ is\ posted\ only\ when\ another\ approves\ it.=Doble control: un ajuste o una reversi\u00f3n creada por un administrador solo se contabiliza cuando otro la aprueba. +Memo=Concepto +Money\ in\ and\ out,\ 14\ days=Entradas y salidas, 14 d\u00edas +Net\ income=Resultado neto +No\ breaks=Sin descuadres +No\ entries\ yet.=A\u00fan no hay asientos. +No\ events.=Sin eventos. +No\ period\ has\ been\ closed\ yet.=A\u00fan no se ha cerrado ning\u00fan periodo. +No\ postings\ to\ this\ account\ in\ this\ currency.=No hay movimientos en esta cuenta en esta moneda. +Nothing\ in\ this\ section.=Nada en esta secci\u00f3n. +Nothing\ waiting.=Nada pendiente. +Opening\ balance=Saldo inicial +Order\ hold=Retenci\u00f3n de orden +Order\ release=Liberaci\u00f3n de orden +Out\ of\ balance=Descuadrado +Overview=Resumen +Owner=Titular +Pending=Pendiente +Period=Periodo +Period\ close=Cierre de periodo +Posted=Contabilizado +Posting=Movimiento +Previous\ hash=Hash anterior +Price,\ 24\ hours=Precio, 24 horas +Problem=Problema +Raise\ an\ adjustment=Crear un ajuste +Raised=Creado +Read-only=Solo lectura +Reason=Motivo +Reconciliation=Conciliaci\u00f3n +Reject=Rechazar +Rejected=Rechazado +Request\ adjustment=Solicitar ajuste +Request\ reversal=Solicitar reversi\u00f3n +Revenue=Ingresos +Reversal=Reversi\u00f3n +Reverse\ entry\ {0}=Revertir asiento {0} +Reverse\ this\ entry=Revertir este asiento +Reversed\ by=Revertido por +Reverses=Revierte +SHA-256=SHA-256 +Saved=Guardado +Sent\ for\ approval.=Enviado para aprobaci\u00f3n. +Show\ as\ a\ T-account=Ver como cuenta T +Show\ trail=Ver rastro +Side=Lado +Staff\ member=Empleado +Statement=Extracto +Status=Estado +Subject=Sujeto +Subledger=Auxiliar +The\ other\ side\ of\ this\ entry\ is\ in\ the\ bank's\ books;\ every\ entry's\ debits\ equal\ its\ credits.=La otra parte de este asiento est\u00e1 en los libros del banco; el debe de cada asiento iguala su haber. +Time=Hora +Total=Total +Total\ assets=Activos totales +Total\ credits=Total haber +Total\ debits=Total debe +Transaction=Movimiento +Transfer=Transferencia +Trial\ balance=Balance de comprobaci\u00f3n +Type=Tipo +Unavailable=No disponible +Valued\ in\ dollars=Valorado en d\u00f3lares +Volume\ ({0})=Volumen ({0}) +Where\ your\ money\ is=D\u00f3nde est\u00e1 tu dinero +Withdrawal=Retiro +Your\ password=Tu contrase\u00f1a +credit=haber +debit=debe +{0}\ account,\ normal\ balance\ {1}.=Cuenta de {0}, saldo normal {1}. +{0}\ breaks={0} descuadres +{0}\ raised\ by\ {1}={0} creado por {1} +{0}\ to\ {1}={0} a {1} +Showing\ the\ latest\ {0}\ of\ {1}\ postings.=Se muestran los {0} movimientos m\u00e1s recientes de {1}. +Cash\ in\ dollar\ accounts,\ 30\ days=Efectivo en cuentas en d\u00f3lares, 30 d\u00edas +error.account-already-has-authenticator-app=Esta cuenta ya tiene una app de autenticaci\u00f3n +error.account-already-has-card=Esa cuenta ya tiene una tarjeta +error.account-frozen=Esa cuenta est\u00e1 congelada +error.add-authenticator-app=A\u00f1ade primero una app de autenticaci\u00f3n +error.address-already-list=Esa direcci\u00f3n ya est\u00e1 en tu lista +error.address-new-used=Esa direcci\u00f3n es nueva: se podr\u00e1 usar a partir de {0} +error.address-wrong-asset=Esa direcci\u00f3n es para {0} +error.administrators=Solo para administradores +error.amount-cannot-zero=Un importe no puede ser cero +error.amount-more-than-zero=El importe debe ser mayor que cero +error.amount-too-large=Ese importe es demasiado grande +error.both-accounts-hold-same-currency=Ambas cuentas deben tener la misma moneda. Usa el cambio para convertir. +error.card-frozen=Esa tarjeta est\u00e1 congelada +error.card-limit-range=El l\u00edmite de una tarjeta va de 0 a {0} +error.cards-issued-checking-accounts=Las tarjetas se emiten sobre cuentas corrientes +error.choose-password-least-characters=Elige una contrase\u00f1a de al menos {0} caracteres +error.choose-two-different-accounts=Elige dos cuentas distintas +error.close-account-before-opening-another=Cierra una cuenta antes de abrir otra +error.code-not-app-shows=Ese c\u00f3digo no es el que muestra tu app +error.coin-withdrawn-address=Solo se retira una moneda a una direcci\u00f3n +error.coins-withdrawn-wallet=Las monedas se retiran de un monedero +error.crypto-held-wallet-fiat-account=Las criptomonedas se guardan en un monedero y el dinero fiat en una cuenta +error.customers-hold-accounts=Solo los clientes tienen cuentas +error.device-not-attested=Este dispositivo no est\u00e1 atestado o tiene root: solo puede mover dinero entre tus propias cuentas +error.e-mail-already-has-account=Ese correo electr\u00f3nico ya tiene una cuenta +error.enter-password-add-device-turn=Introduce tu contrase\u00f1a para a\u00f1adir un dispositivo, o activa una app de autenticaci\u00f3n +error.account-locked=Demasiadas contrase\u00f1as incorrectas: esta cuenta est\u00e1 bloqueada por un tiempo +error.sign-or-enter-password-revoke=Apru\u00e9balo en otro dispositivo o introduce tu contrase\u00f1a +error.enter-password-add-app=Introduce tu contrase\u00f1a para a\u00f1adir una app de autenticaci\u00f3n +error.entry-needs-two-sides=Un asiento necesita dos lados +error.idempotency-key-reused-order=Esa Idempotency-Key ya se us\u00f3 para otra orden +error.idempotency-key-reused=Esa Idempotency-Key ya se us\u00f3 para otra solicitud +error.insufficient-funds=Fondos insuficientes +error.key-ec-p-256-public=La clave debe ser una clave p\u00fablica EC P-256 +error.limit-order-names-no-budget=Una orden limitada no indica presupuesto +error.limit-order-needs-price=Una orden limitada necesita un precio +error.market-buy-names-budget=Una compra a mercado indica solo un presupuesto +error.market-sell-names-quantity=Una venta a mercado indica solo una cantidad +error.more-than-crypto-movement-may=Es m\u00e1s de lo que puede mover un movimiento cripto +error.more-than-payment-may-move=Es m\u00e1s de lo que puede mover un pago +error.move-money-between-own-accounts=Mueve dinero entre tus propias cuentas en su lugar +error.name-account-pay-either-account=Indica la cuenta de la que pagar y una cuenta o un beneficiario al que pagar +error.name-payee=Indica el beneficiario +error.name-too-long=Ese nombre es demasiado largo +error.needs-device-signature=Esto necesita una firma de tu dispositivo: {0} +error.new-payee-receive-until-their=Un beneficiario nuevo solo puede recibir {0} hasta que termine su periodo de espera +error.no-customer-has-address=Ning\u00fan cliente tiene esa direcci\u00f3n +error.no-such-account=No existe esa cuenta +error.no-such-address=No existe esa direcci\u00f3n +error.no-such-asset=No existe ese activo +error.no-such-card=No existe esa tarjeta +error.no-such-device=No existe ese dispositivo +error.no-such-kind-account=No existe ese tipo de cuenta +error.no-such-market=No existe ese mercado +error.no-such-order=No existe esa orden +error.no-such-passkey=No existe esa llave de acceso +error.no-such-payee=No existe ese beneficiario +error.not-address=Eso no es una direcci\u00f3n +error.not-current-password=Esa no es tu contrase\u00f1a actual +error.not-e-mail-address=Eso no es un correo electr\u00f3nico +error.not-found=No encontrado +error.nothing-register=Nada que registrar +error.open-checking-first=Abre primero una cuenta corriente en {0} +error.open-checking-savings-wallet-account=Abre una cuenta CHECKING, SAVINGS o WALLET +error.open-wallet-first=Abre primero un monedero de {0} +error.order-already-status=Esa orden ya est\u00e1 {0} +error.order-buy-sell=Una orden es un BUY o un SELL +error.order-limit-market=Una orden es LIMIT o MARKET +error.order-too-large=Esa orden es demasiado grande +error.over-card-daily-limit=Supera el l\u00edmite diario de la tarjeta +error.password-cannot-e-mail-address=Tu contrase\u00f1a no puede ser tu correo electr\u00f3nico +error.password-too-long=Esa contrase\u00f1a es demasiado larga +error.payee-already-list=Ese beneficiario ya est\u00e1 en tu lista +error.payee-cannot-receive=Ese beneficiario no puede recibir {0} +error.quantity-whole-number-unit-lots=La cantidad debe ser un n\u00famero entero de lotes de {0} unidades +error.remove-address-before-adding-another=Quita una direcci\u00f3n antes de a\u00f1adir otra +error.remove-device-before-adding-another=Quita un dispositivo antes de a\u00f1adir otro +error.remove-payee-before-adding-another=Quita un beneficiario antes de a\u00f1adir otro +error.say-which-account-open=Indica qu\u00e9 cuenta abrir +error.send-address-amount=Env\u00eda la direcci\u00f3n y el importe +error.send-address=Env\u00eda la direcci\u00f3n +error.send-device-public-key=Env\u00eda la clave p\u00fablica del dispositivo +error.send-idempotency-key-16-80=Env\u00eda una Idempotency-Key de 16 a 80 caracteres +error.send-limit=Env\u00eda el l\u00edmite +error.send-order=Env\u00eda la orden +error.sign-again-add-device-sign=Inicia sesi\u00f3n de nuevo para a\u00f1adir un dispositivo: el inicio de sesi\u00f3n debe tener pocos minutos +error.sign-in-first=Inicia sesi\u00f3n primero +error.tell-us-name=Dinos tu nombre +error.there-no-price=No hay precio para {0} +error.withdraw-address-list=Retira solo a una direcci\u00f3n de tu lista +error.would-pass-crypto-limit-day=Eso superar\u00eda tu l\u00edmite cripto del d\u00eda +error.would-pass-limit-day=Eso superar\u00eda tu l\u00edmite del d\u00eda +Language=Idioma +Bastion\ cannot\ start=Bastion no puede iniciarse +The\ network\ protection\ could\ not\ be\ installed,\ so\ Bastion\ will\ not\ connect.\ Reinstall\ the\ app.=No se pudo instalar la protecci\u00f3n de red, as\u00ed que Bastion no se conectar\u00e1. Reinstala la aplicaci\u00f3n. +A\ payee=Un beneficiario +A\ vault\ opened\ by\ the\ PIN\ (PBKDF2,\ {0}\ rounds,\ peppered\ from\ the\ OS\ store);\ {1}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys=Una b\u00f3veda que abre el PIN (PBKDF2, {0} rondas, con pimienta del almac\u00e9n del sistema); {1} intentos fallidos seguidos borran las claves +A\ vault\ opened\ by\ the\ PIN\ (PBKDF2,\ {0}\ rounds,\ peppered\ from\ the\ OS\ store);\ {1}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys.\ Wrong\ tries\ so\ far\:\ {2}=Una b\u00f3veda que abre el PIN (PBKDF2, {0} rondas, con pimienta del almac\u00e9n del sistema); {1} intentos fallidos seguidos borran las claves. Intentos fallidos hasta ahora: {2} +Active=Activa +After\ {0}s\ idle\ or\ {1}s\ in\ the\ background=Tras {0} s inactiva o {1} s en segundo plano +AppShield\ engine\ {0}\:\ {1}=Motor AppShield {0}: {1} +Available;\ turn\ it\ on\ in\ Settings=Disponible; act\u00edvelo en Ajustes +Biometric\ copy\ simulated\:\ a\ file\ gated\ by\ the\ simulator's\ Biometric\ Simulation\ menu=Copia biom\u00e9trica simulada: un archivo protegido por el men\u00fa Biometric Simulation del simulador +Can\ read\ the\ screen\:\ {0}=Pueden leer la pantalla: {0} +Cash\ withdrawal=Retirada de efectivo +Cloud\ build=Compilaci\u00f3n en la nube +Copied\ numbers\ are\ wiped\ after\ {0}s=Los n\u00fameros copiados se borran tras {0} s +Declared\ {0};\ applied\ by\ the\ build\ server=Declarado {0}; lo aplica el servidor de compilaci\u00f3n +Deposit=Dep\u00f3sito +Development\ server\ {0}\ is\ exempt\:\ plain\ HTTP\ to\ this\ machine\ only=El servidor de desarrollo {0} est\u00e1 exento: HTTP simple solo a esta m\u00e1quina +Enter\ an\ amount,\ like\ {0}.=Introduzca un importe, como {0}. +Every\ entry\ balances\ and\ every\ balance\ matches\ its\ postings.=Cada asiento cuadra y cada saldo coincide con sus apuntes. +Every\ request\ is\ https;\ http\ is\ refused\ ({0}\ refused)=Toda solicitud es https; http se rechaza ({0} rechazadas) +Keychain\ item\ with\ biometric\ access\ control\ (current\ set)\:\ released\ only\ after\ Face\ ID\ or\ Touch\ ID=Elemento del llavero con control de acceso biom\u00e9trico (conjunto actual): se entrega solo tras Face ID o Touch ID +Keystore\ key\ that\ requires\ user\ authentication\:\ released\ only\ after\ a\ strong\ biometric=Clave del Keystore que exige autenticaci\u00f3n: se entrega solo tras una biometr\u00eda fuerte +Level\ {0},\ mapping\ {1}=Nivel {0}, mapa {1} +Limit=L\u00edmite +Market=Mercado +My\ accounts=Mis cuentas +No\ such\ account=No existe esa cuenta +None\ enrolled\:\ the\ PIN\ or\ the\ password\ unlocks\ instead=Ninguna registrada: desbloquea el PIN o la contrase\u00f1a +Not\ in\ simulator=No en el simulador +Not\ set\:\ Settings,\ Unlock\ PIN=Sin configurar: Ajustes, PIN de desbloqueo +On\ start,\ on\ return\ and\ when\ idle\:\ PIN=Al iniciar, al volver y en reposo: PIN +On\ start,\ on\ return\ and\ when\ idle\:\ biometric=Al iniciar, al volver y en reposo: biometr\u00eda +On\ start,\ on\ return\ and\ when\ idle\:\ biometric,\ then\ PIN=Al iniciar, al volver y en reposo: biometr\u00eda, luego PIN +On\ start,\ on\ return\ and\ when\ idle\:\ the\ password=Al iniciar, al volver y en reposo: la contrase\u00f1a +Only\ {0}=Solo {0} +Opened\ only\ by\ the\ unlock\ PIN=Solo lo abre el PIN de desbloqueo +Opened\ without\ asking\:\ set\ up\ a\ biometric\ or\ a\ PIN=Se abre sin preguntar: configure una biometr\u00eda o un PIN +P-256,\ registered\ as\ {0};\ signs\ every\ payment=P-256, registrada como {0}; firma cada pago +Released\ by\ the\ platform\ after\ a\ biometric=La plataforma la entrega tras una biometr\u00eda +Sealed\ under\ the\ app\ key,\ which\ opens\ without\ asking\ until\ a\ biometric\ or\ PIN\ is\ set\ up=Sellado con la clave de la app, que se abre sin preguntar hasta configurar una biometr\u00eda o un PIN +Sealed\ under\ the\ app\ key\:\ unreadable\ until\ the\ lock\ is\ opened=Sellado con la clave de la app: ilegible hasta abrir el bloqueo +The\ audit\ chain\ is\ intact.=La cadena de auditor\u00eda est\u00e1 intacta. +The\ PIN\ could\ not\ be\ removed.=No se pudo quitar el PIN. +This\ device\ looks\ modified\:\ {0}.\ The\ bank\ will\ not\ move\ money\ from\ it.=Este dispositivo parece modificado: {0}. El banco no mover\u00e1 dinero desde \u00e9l. +This\ platform\ has\ no\ biometric-bound\ store\:\ set\ up\ a\ PIN=Esta plataforma no tiene almac\u00e9n ligado a la biometr\u00eda: configure un PIN +Try\ again=Reintentar +Unlock\ with\ a\ biometric\ is\ on=El desbloqueo biom\u00e9trico est\u00e1 activado +Warning=Advertencia +android.playIntegrity\={0}=android.playIntegrity={0} +ios.blockScreenshotsOnEnterBackground\={0}=ios.blockScreenshotsOnEnterBackground={0} +ios.detectJailbreak\={0}=ios.detectJailbreak={0} +{0}\ hosts\ pinned={0} hosts fijados +{0}\ hosts\ pinned,\ {1}\ mismatches\ refused={0} hosts fijados, {1} discrepancias rechazadas +{0}\ hosts,\ version\ {1}={0} hosts, versi\u00f3n {1} +{0}\ hosts,\ version\ {1}\ (refresh\ due)={0} hosts, versi\u00f3n {1} (toca actualizar) +error.price-outside-collar=El precio debe estar dentro del {0}% del precio \u00edndice +error.no-such-entry=No existe ese asiento contable +error.entry-cannot-reversed=Ese asiento no se puede revertir +error.entry-already-reversed=Ese asiento ya fue revertido +error.period-closed=Esa fecha pertenece a un per\u00edodo cerrado +error.period-end-past-last-close=Un per\u00edodo debe terminar en el pasado, despu\u00e9s del \u00faltimo cerrado +error.four-eyes-second-administrator=Un segundo administrador debe aprobar esto +error.approval-not-pending=Esa solicitud ya no espera aprobaci\u00f3n +error.staff-only=Solo para el personal +error.enter-password-confirm=Introduce tu contrase\u00f1a para confirmar +error.not-number=Eso no es un n\u00famero +error.name-entity=Indica la entidad que quieres rastrear +error.format-csv-json=El formato es csv o json +Opening=Inicial +Closing=Final +Posted.=Contabilizado. +Above\ the\ approval\ threshold,\ a\ second\ administrator\ must\ approve\ it.=Por encima del umbral de aprobaci\u00f3n, un segundo administrador debe aprobarlo. +Only\ the\ latest\ {0}\ postings\ are\ listed;\ the\ totals\ and\ balances\ cover\ them\ all.=Solo se muestran los \u00faltimos {0} asientos; los totales y saldos los incluyen todos. +Receipt=Recibo +Completed=Completada +Fee=Comisi\u00f3n +Category=Categor\u00eda +Show=Mostrar +Columns=Columnas +Card=Tarjeta +Other=Otro +Card\ details=Detalles de la tarjeta +Virtual\ debit=D\u00e9bito virtual +Expires=Vence +Cardholder=Titular +{0}\ used\ today,\ {1}\ left={0} usado hoy, quedan {1} +Recent\ card\ payments=Pagos recientes con tarjeta +This\ card\ has\ not\ been\ used\ yet.=Esta tarjeta a\u00fan no se ha usado. +Account\ ID=ID de cuenta +No\ movements\ in\ this\ period.=No hay movimientos en este periodo. +Card\ ID=ID de tarjeta +Entry\ id=ID del asiento +Currency=Moneda +Books=Libros +All\ accounts=Todas las cuentas +As\ of=A fecha de +File=Archivo +Made\ by=Creado por +Generated=Generado +Print=Imprimir +Printing\ is\ not\ available\ on\ this\ device.=La impresi\u00f3n no est\u00e1 disponible en este dispositivo. +Could\ not\ print\:\ {0}=No se pudo imprimir: {0} +Sent\ to\ the\ printer.=Enviado a la impresora. +Page\ {0}\ of\ {1}=P\u00e1gina {0} de {1} +{0}\ (continued)={0} (continuaci\u00f3n) +Device\ key\ ID=ID de la clave del dispositivo +Passkey\ ID=ID de la llave de acceso +Copy\ {0}=Copiar {0} +Copied=Copiado +Or\ sign\ in\ with\ your\ e-mail\ and\ password.\ An\ account\ with\ a\ passkey\ takes\ its\ password\ only\ with\ a\ recovery\ code.=O inicia sesi\u00f3n con tu correo y contrase\u00f1a. Una cuenta con llave de acceso solo acepta la contrase\u00f1a junto con un c\u00f3digo de recuperaci\u00f3n. +This\ account\ signs\ in\ with\ its\ passkey.\ Use\ the\ passkey,\ or\ ask\ the\ bank\ to\ help\ you\ recover\ the\ account.=Esta cuenta inicia sesi\u00f3n con su llave de acceso. Usa la llave, o pide al banco que te ayude a recuperar la cuenta. +This\ account\ signs\ in\ with\ its\ passkey.\ Without\ it,\ enter\ one\ of\ your\ recovery\ codes.=Esta cuenta inicia sesi\u00f3n con su llave de acceso. Sin ella, introduce uno de tus c\u00f3digos de recuperaci\u00f3n. +Custody=Custodia +Held\ on\ chain\ {0}=En la cadena {0} +Owed\ to\ customers\ {0}=Adeudado a clientes {0} +Hot\ wallet=Billetera caliente +In\ transit=En tr\u00e1nsito +Withdrawals\ not\ yet\ confirmed=Retiros a\u00fan no confirmados +Receive\ crypto=Recibir cripto +Chain\ {0}=Cadena {0} +Your\ {0}\ address=Tu direcci\u00f3n {0} +Credited\ after\ {0}\ confirmations,\ usually\ a\ few\ minutes.=Se acredita tras {0} confirmaciones, normalmente en pocos minutos. +On\ the\ chain=En la cadena +Nothing\ has\ crossed\ the\ chain\ yet.=Todav\u00eda no ha pasado nada por la cadena. +To\ {0}=A {0} +Final=Final +Failed=Fallido +Confirming=Confirmando +Sending=Enviando +Set\ up\ a\ second\ factor=Configura un segundo factor +Open\ Settings=Abrir ajustes +error.too-many-new-addresses=Has a\u00f1adido tantas direcciones como permite un d\u00eda; vuelve a intentarlo ma\u00f1ana +error.second-factor-add-device=Configura una app de autenticaci\u00f3n o una llave de acceso e inicia sesi\u00f3n con ella para a\u00f1adir un dispositivo +error.too-many-new-payees=Has a\u00f1adido tantos destinatarios como permite un d\u00eda; vuelve a intentarlo ma\u00f1ana diff --git a/scripts/bastion/common/src/main/l10n/Bundle_fr.properties b/scripts/bastion/common/src/main/l10n/Bundle_fr.properties new file mode 100644 index 00000000000..410bb982a04 --- /dev/null +++ b/scripts/bastion/common/src/main/l10n/Bundle_fr.properties @@ -0,0 +1,745 @@ +# Bastion in French. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +# These translations were generated automatically as a sample and have not +# been reviewed by a native speaker. Review them before shipping. + +@rtl=false +language.name=Fran\u00e7ais +Account=Compte +Frozen=Gel\u00e9 +Move\ money=D\u00e9placer de l'argent +Send=Envoyer +Add\ money=Ajouter de l'argent +Withdraw=Retirer +Order\ a\ card=Commander une carte +No\ card=Aucune carte +History=Historique +Nothing\ has\ moved\ in\ this\ account\ yet.=Aucun mouvement sur ce compte pour l'instant. +Money\ in=Entr\u00e9es +Money\ out=Sorties +Amount=Montant +Add=Ajouter +Withdraw\ cash=Retirer des esp\u00e8ces +Cancel=Annuler +Not\ done=Non effectu\u00e9 +Administration=Administration +Administrators\ only.=R\u00e9serv\u00e9 aux administrateurs. +Recomputed\ from\ the\ stored\ rows\ each\ time\ you\ ask.=Recalcul\u00e9 \u00e0 partir des lignes stock\u00e9es \u00e0 chaque demande. +Verify\ the\ audit\ log=V\u00e9rifier le journal d'audit +Verify\ the\ ledger=V\u00e9rifier le grand livre +Audit\ log=Journal d'audit +Ledger=Grand livre +Intact=Intact +Problems\ found=Probl\u00e8mes d\u00e9tect\u00e9s +Cards=Cartes +No\ cards\ yet.\ Order\ one\ from\ a\ checking\ account.=Aucune carte pour l'instant. Commandez-en une depuis un compte courant. +Bastion=Bastion +Spends\ up\ to\ {0}\ a\ day=D\u00e9pense jusqu'\u00e0 {0} par jour +Unfreeze=D\u00e9geler +Freeze=Geler +Not\ changed=Non modifi\u00e9 +Daily\ limit=Plafond quotidien +Save=Enregistrer +Sign\ in\ first=Connectez-vous d'abord +Password=Mot de passe +Continue=Continuer +Confirm\ it\ is\ you=Confirmez que c'est vous +Sign\ with\ this\ device=Signer avec cet appareil +Payments\ leaving\ your\ accounts\ are\ signed\ by\ a\ key\ that\ never\ leaves\ this\ phone.\ Register\ this\ device\ to\ continue.=Les paiements sortant de vos comptes sont sign\u00e9s par une cl\u00e9 qui ne quitte jamais ce t\u00e9l\u00e9phone. Enregistrez cet appareil pour continuer. +Register=Enregistrer +Not\ now=Pas maintenant +Exchange=\u00c9change +Markets=March\u00e9s +No\ markets\ are\ open.=Aucun march\u00e9 n'est ouvert. +Index\ {0}=Indice {0} +Your\ trades=Vos transactions +You\ have\ not\ traded\ yet.=Vous n'avez pas encore effectu\u00e9 de transaction. +Welcome=Bienvenue +Hello,\ {0}=Bonjour, {0} +Total\ balance=Solde total +Not\ counted,\ no\ dollar\ price\:\ {0}=Non comptabilis\u00e9, pas de prix en dollars : {0} +Pay\ someone=Payer quelqu'un +Trade=Trader +Accounts=Comptes +You\ have\ no\ accounts\ yet.=Vous n'avez pas encore de compte. +Open\ an\ account=Ouvrir un compte +Checking=Courant +Savings=\u00c9pargne +Wallet=Portefeuille +This\ device\ cannot\ sign\ payments\ yet=Cet appareil ne peut pas encore signer de paiements +Payments\ to\ other\ people\ and\ crypto\ withdrawals\ need\ a\ key\ that\ lives\ only\ on\ this\ phone.=Les paiements \u00e0 d'autres personnes et les retraits de cryptomonnaies n\u00e9cessitent une cl\u00e9 qui n'existe que sur ce t\u00e9l\u00e9phone. +Register\ this\ device=Enregistrer cet appareil +{0}\ wallet=Portefeuille {0} +{0}\ checking=Compte courant en {0} +{0}\ savings=\u00c9pargne en {0} +Name\ (optional)=Nom (facultatif) +Open=Ouvrir +Not\ opened=Non ouvert +Locked=Verrouill\u00e9 +Bastion\ is\ locked=Bastion est verrouill\u00e9 +Unlock=D\u00e9verrouiller +Sign\ in\ again=Reconnectez-vous +Not\ recognised.\ Try\ again.=Non reconnu. R\u00e9essayez. +Last\ price=Dernier prix +Order\ book=Carnet d'ordres +Place\ an\ order=Passer un ordre +Your\ open\ orders=Vos ordres ouverts +Bid\ {0}\ -\ Ask\ {1}=Achat {0} - Vente {1} +The\ book\ is\ empty.=Le carnet est vide. +Buy=Acheter +Sell=Vendre +at\ {0},\ {1}\ filled=\u00e0 {0}, {1} ex\u00e9cut\u00e9 +No\ open\ orders\ in\ this\ market.=Aucun ordre ouvert sur ce march\u00e9. +Price\ in\ {0}=Prix en {0} +Amount\ of\ {0}=Quantit\u00e9 de {0} +Spend,\ in\ {0}=D\u00e9pense, en {0} +In\ steps\ of\ {0}=Par multiples de {0} +Place\ order=Passer l'ordre +Confirm\ order=Confirmer l'ordre +Place=Passer +Order\ {0}=Ordre {0} +Enter\ how\ much\ to\ spend.=Saisissez le montant \u00e0 d\u00e9penser. +Enter\ an\ amount\ in\ steps\ of\ {0}.=Saisissez une quantit\u00e9 par multiples de {0}. +Enter\ a\ price.=Saisissez un prix. +Buy\ {0}\ at\ the\ best\ prices,\ spending\ up\ to\ {1}?=Acheter {0} aux meilleurs prix, en d\u00e9pensant jusqu'\u00e0 {1} ? +Sell\ {0}\ at\ the\ best\ prices?=Vendre {0} aux meilleurs prix ? +Buy\ {0}\ at\ {1}\ (about\ {2})?=Acheter {0} \u00e0 {1} (environ {2}) ? +Sell\ {0}\ at\ {1}\ (about\ {2})?=Vendre {0} \u00e0 {1} (environ {2}) ? +filled=ex\u00e9cut\u00e9 +on\ the\ book=dans le carnet +ended,\ the\ rest\ unfilled=termin\u00e9, le reste non ex\u00e9cut\u00e9 +Bought\ {0}=Achet\u00e9 {0} +Sold\ {0}=Vendu {0} +at\ {0},\ fee\ {1}=\u00e0 {0}, frais {1} +Payees=B\u00e9n\u00e9ficiaires +Send\ crypto=Envoyer des cryptos +Security\ Center=Centre de s\u00e9curit\u00e9 +Settings=R\u00e9glages +Sign\ out=Se d\u00e9connecter +Bank\ with\ Bastion=Bancarisez-vous avec Bastion +Takes\ two\ minutes.\ You\ will\ set\ up\ a\ second\ factor\ and\ this\ phone's\ signing\ key\ as\ you\ go.=Cela prend deux minutes. Vous configurerez un second facteur et la cl\u00e9 de signature de ce t\u00e9l\u00e9phone en cours de route. +Your\ name=Votre nom +E-mail=E-mail +Password\ again=Mot de passe (confirmation) +Create\ account=Cr\u00e9er un compte +At\ least\ {0}\ characters=Au moins {0} caract\u00e8res +Both\ passwords\ match=Les deux mots de passe correspondent +Not\ your\ e-mail\ address=Pas votre adresse e-mail +A\ few\ unrelated\ words\ make\ a\ password\ that\ is\ long\ and\ easy\ to\ remember.=Quelques mots sans rapport forment un mot de passe long et facile \u00e0 retenir. +Sign\ in\ to\ continue.=Connectez-vous pour continuer. +Authenticator\ app=Application d'authentification +Add\ a\ second\ factor=Ajouter un second facteur +Scan\ this\ with\ an\ authenticator\ app.\ From\ now\ on\ a\ sign-in\ with\ your\ password\ also\ asks\ for\ the\ code\ it\ shows.=Scannez ceci avec une application d'authentification. D\u00e9sormais, la connexion avec votre mot de passe demandera aussi le code qu'elle affiche. +Or\ enter\ this\ key=Ou saisissez cette cl\u00e9 +Copy\ key=Copier la cl\u00e9 +Copy\ codes=Copier les codes +Copied.\ The\ clipboard\ clears\ itself\ in\ a\ minute.=Copi\u00e9. Le presse-papiers se vide tout seul dans une minute. +Open\ authenticator=Ouvrir l'application d'authentification +Six-digit\ code=Code \u00e0 six chiffres +Turn\ on=Activer +New\ recovery\ codes=Nouveaux codes de r\u00e9cup\u00e9ration +Recovery\ codes=Codes de r\u00e9cup\u00e9ration +Save\ your\ recovery\ codes=Conservez vos codes de r\u00e9cup\u00e9ration +Each\ signs\ you\ in\ once\ if\ you\ lose\ your\ phone.\ Keep\ them\ somewhere\ safe\ and\ offline.\ They\ will\ not\ be\ shown\ again.=Chacun vous connecte une fois si vous perdez votre t\u00e9l\u00e9phone. Gardez-les en lieu s\u00fbr et hors ligne. Ils ne seront plus affich\u00e9s. +I\ have\ saved\ them=Je les ai enregistr\u00e9s +Passkey=Cl\u00e9 d'acc\u00e8s +Sign\ in\ with\ a\ passkey=Se connecter avec une cl\u00e9 d'acc\u00e8s +A\ passkey\ is\ kept\ by\ your\ phone\ and\ unlocked\ by\ your\ face,\ fingerprint\ or\ screen\ lock.\ It\ cannot\ be\ phished,\ and\ replaces\ both\ the\ password\ and\ the\ code.=Une cl\u00e9 d'acc\u00e8s est conserv\u00e9e par votre t\u00e9l\u00e9phone et d\u00e9verrouill\u00e9e par votre visage, votre empreinte ou le verrouillage de l'\u00e9cran. Elle r\u00e9siste \u00e0 l'hame\u00e7onnage et remplace \u00e0 la fois le mot de passe et le code. +Create\ a\ passkey=Cr\u00e9er une cl\u00e9 d'acc\u00e8s +Bastion\ on\ {0}=Bastion sur {0} +Unlock\ with\ your\ face\ or\ fingerprint?=D\u00e9verrouiller avec votre visage ou votre empreinte ? +Bastion\ locks\ itself\ when\ you\ leave\ it.\ With\ this\ on,\ a\ biometric\ unlocks\ it;\ with\ it\ off,\ you\ sign\ in\ again.\ The\ device\ passcode\ is\ never\ accepted.=Bastion se verrouille tout seul quand vous le quittez. Avec cette option, la biom\u00e9trie le d\u00e9verrouille ; sans elle, vous vous reconnectez. Le code de l'appareil n'est jamais accept\u00e9. +Use\ biometrics=Utiliser la biom\u00e9trie +This\ device=Cet appareil +Let\ this\ phone\ sign\ your\ payments=Laissez ce t\u00e9l\u00e9phone signer vos paiements +Bastion\ makes\ a\ key\ on\ this\ phone\ and\ gives\ the\ bank\ only\ its\ public\ half.\ Payments\ to\ other\ people\ and\ crypto\ withdrawals\ must\ carry\ its\ signature,\ so\ a\ stolen\ password\ alone\ cannot\ move\ your\ money.=Bastion cr\u00e9e une cl\u00e9 sur ce t\u00e9l\u00e9phone et ne donne \u00e0 la banque que sa moiti\u00e9 publique. Les paiements \u00e0 d'autres personnes et les retraits de cryptomonnaies doivent porter sa signature : un mot de passe vol\u00e9 ne suffit donc pas \u00e0 d\u00e9placer votre argent. +Later=Plus tard +Add\ a\ payee=Ajouter un b\u00e9n\u00e9ficiaire +No\ payees\ yet.\ Add\ one\ by\ the\ e-mail\ address\ they\ bank\ with.=Aucun b\u00e9n\u00e9ficiaire pour l'instant. Ajoutez-en un avec l'adresse e-mail qu'il utilise dans la banque. +New\ payee\:\ limited\ until\ {0}=Nouveau b\u00e9n\u00e9ficiaire : limit\u00e9 jusqu'au {0} +Cooling\ off=D\u00e9lai d'attente +Ready=Pr\u00eat +Their\ e-mail=Son e-mail +Name\ to\ show=Nom \u00e0 afficher +Payee\ added.\ It\ is\ limited\ while\ it\ cools\ off.=B\u00e9n\u00e9ficiaire ajout\u00e9. Il est limit\u00e9 pendant le d\u00e9lai d'attente. +Not\ added=Non ajout\u00e9 +Rename=Renommer +Remove=Retirer +Remove\ payee=Retirer le b\u00e9n\u00e9ficiaire +Remove\ {0}?\ Adding\ them\ again\ starts\ a\ new\ cooling-off.=Retirer {0} ? Le r\u00e9-ajouter relancera un nouveau d\u00e9lai d'attente. +Done=Termin\u00e9 +Sent=Envoy\u00e9 +Reference=R\u00e9f\u00e9rence +When=Quand +Note=Note +What\ protects\ your\ account\ on\ this\ device.\ Nothing\ here\ is\ decoration\:\ each\ line\ is\ read\ from\ the\ app\ as\ it\ runs.=Ce qui prot\u00e8ge votre compte sur cet appareil. Rien ici n'est d\u00e9coratif : chaque ligne est lue dans l'application en cours d'ex\u00e9cution. +Back=Retour +Unlock\ with\ fingerprint\ or\ face=D\u00e9verrouiller avec l'empreinte ou le visage +This\ device\ has\ no\ biometric\ enrolled.\ The\ lock\ asks\ for\ your\ password.=Cet appareil n'a aucune donn\u00e9e biom\u00e9trique enregistr\u00e9e. Le verrouillage demande votre mot de passe. +Off=D\u00e9sactiv\u00e9 +Sign-in=Connexion +{0}\ recovery\ codes\ left=Il reste {0} codes de r\u00e9cup\u00e9ration +Adds\ a\ six-digit\ code\ to\ every\ password\ sign-in.=Ajoute un code \u00e0 six chiffres \u00e0 chaque connexion par mot de passe. +On=Activ\u00e9 +Payment\ signing\ key=Cl\u00e9 de signature des paiements +Payments\ from\ this\ phone\ are\ signed\ by\ a\ key\ that\ never\ leaves\ it.=Les paiements depuis ce t\u00e9l\u00e9phone sont sign\u00e9s par une cl\u00e9 qui ne le quitte jamais. +Register\ this\ device\ to\ sign\ payments.=Enregistrez cet appareil pour signer les paiements. +Sign\ in=Se connecter +Your\ bank,\ locked\ down.=Votre banque, verrouill\u00e9e. +Verification=V\u00e9rification +Enter\ your\ code=Saisissez votre code +Open\ your\ authenticator\ app\ and\ enter\ the\ six-digit\ code,\ or\ one\ of\ your\ recovery\ codes.=Ouvrez votre application d'authentification et saisissez le code \u00e0 six chiffres, ou l'un de vos codes de r\u00e9cup\u00e9ration. +Code=Code +You\ have\ no\ account\ that\ can\ send\ money.=Vous n'avez aucun compte permettant d'envoyer de l'argent. +Add\ a\ payee\ first,\ under\ Payees.=Ajoutez d'abord un b\u00e9n\u00e9ficiaire, dans B\u00e9n\u00e9ficiaires. +Note\ (optional)=Note (facultatif) +Review=V\u00e9rifier +From=De +To=Vers +Choose\ who\ to\ pay.=Choisissez qui payer. +That\ is\ more\ than\ this\ account\ holds.=C'est plus que le solde de ce compte. +Choose\ two\ different\ accounts.=Choisissez deux comptes diff\u00e9rents. +Confirm=Confirmer +Send\ {0}\ from\ {1}\ to\ {2}?=Envoyer {0} de {1} vers {2} ? +Loading...=Chargement... +Open\ a\ crypto\ wallet\ to\ send\ coins\ from.=Ouvrez un portefeuille crypto depuis lequel envoyer des coins. +Your\ addresses=Vos adresses +Add\ an\ address=Ajouter une adresse +No\ addresses\ yet.\ Coins\ can\ be\ sent\ only\ to\ an\ address\ on\ this\ list.=Aucune adresse pour l'instant. Les coins ne peuvent \u00eatre envoy\u00e9s qu'\u00e0 une adresse de cette liste. +Usable\ from\ {0}=Utilisable \u00e0 partir du {0} +Waiting=En attente +No\ {0}\ address\ is\ ready\ yet.=Aucune adresse {0} n'est encore pr\u00eate. +Choose\ an\ address.=Choisissez une adresse. +Enter\ an\ amount.=Saisissez un montant. +That\ is\ more\ than\ this\ wallet\ holds.=C'est plus que le solde de ce portefeuille. +Send\ {0}\ to\ {1}?\ This\ cannot\ be\ undone.=Envoyer {0} vers {1} ? Cette action est irr\u00e9versible. +Address=Adresse +Label,\ like\ My\ hardware\ wallet=Libell\u00e9, par exemple Mon portefeuille mat\u00e9riel +Address\ added=Adresse ajout\u00e9e +For\ your\ safety\ it\ can\ be\ used\ from\ {0}.=Pour votre s\u00e9curit\u00e9, elle sera utilisable \u00e0 partir du {0}. +Remove\ address=Retirer l'adresse +Remove\ {0}?\ Adding\ it\ again\ starts\ a\ new\ wait.=Retirer {0} ? La r\u00e9-ajouter relancera une nouvelle attente. +Not\ removed=Non retir\u00e9 +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=Impossible de joindre le serveur. V\u00e9rifiez votre connexion. +Something\ went\ wrong\ ({0}).\ Try\ again.=Un probl\u00e8me est survenu ({0}). R\u00e9essayez. +The\ server\ changed.\ Sign\ in\ again.=Le serveur a chang\u00e9. Reconnectez-vous. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=Trop de tentatives. Patientez une minute et r\u00e9essayez. +This\ account\ is\ locked\ for\ a\ while\ after\ too\ many\ attempts.=Ce compte est verrouill\u00e9 pendant un moment apr\u00e8s trop de tentatives. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=Cet e-mail et ce mot de passe ne correspondent \u00e0 aucun compte. +That\ code\ is\ not\ right.\ Codes\ change\ every\ 30\ seconds.=Ce code n'est pas correct. Les codes changent toutes les 30 secondes. +Passkey\ sign-in\ is\ not\ available\ right\ now.=La connexion par cl\u00e9 d'acc\u00e8s n'est pas disponible pour le moment. +That\ passkey\ was\ not\ accepted.=Cette cl\u00e9 d'acc\u00e8s n'a pas \u00e9t\u00e9 accept\u00e9e. +Passkey\ sign-in\ was\ cancelled.=La connexion par cl\u00e9 d'acc\u00e8s a \u00e9t\u00e9 annul\u00e9e. +Signing\ in\ failed.\ Try\ again.=La connexion a \u00e9chou\u00e9. R\u00e9essayez. +Sign\ in\ again\ to\ add\ a\ passkey.=Reconnectez-vous pour ajouter une cl\u00e9 d'acc\u00e8s. +Passkeys\ are\ not\ available\ right\ now.=Les cl\u00e9s d'acc\u00e8s ne sont pas disponibles pour le moment. +That\ passkey\ was\ not\ saved.=Cette cl\u00e9 d'acc\u00e8s n'a pas \u00e9t\u00e9 enregistr\u00e9e. +Creating\ the\ passkey\ was\ cancelled.=La cr\u00e9ation de la cl\u00e9 d'acc\u00e8s a \u00e9t\u00e9 annul\u00e9e. +This\ device\ has\ no\ secure\ storage\ for\ the\ key.=Cet appareil n'a pas de stockage s\u00e9curis\u00e9 pour la cl\u00e9. +Unlock\ Bastion=D\u00e9verrouiller Bastion +Unlock\ Bastion\ and\ the\ keys\ that\ sign\ your\ payments=D\u00e9verrouillez Bastion et les cl\u00e9s qui signent vos paiements +Network=R\u00e9seau +Keys\ and\ sign-in=Cl\u00e9s et connexion +Build\ hardening=Durcissement de la compilation +No\ cleartext=Pas de texte en clair +Development\ server\ =Serveur de d\u00e9veloppement +\ is\ exempt\:\ plain\ HTTP\ to\ this\ machine\ only=\ est exempt\u00e9 : HTTP simple vers cette machine uniquement +Every\ request\ is\ https;\ http\ is\ refused=Toutes les requ\u00eates sont en https ; http est refus\u00e9 +Host\ allow-list=Liste d'h\u00f4tes autoris\u00e9s +Exempt\ while\ the\ development\ server\ is\ in\ use=Exempt\u00e9e tant que le serveur de d\u00e9veloppement est utilis\u00e9 +Only\ =Seulement +Over-the-air\ pins=\u00c9pingles over-the-air +Published\ by\ the\ attestation\ service;\ none\ in\ this\ build=Publi\u00e9es par le service d'attestation ; aucune dans cette compilation +\ hosts,\ version\ =\ h\u00f4tes, version +\ (refresh\ due)=\ (actualisation due) +App\ attestation\ token=Jeton d'attestation de l'application +AppShield\ engine\ =Moteur AppShield +AppShield\ needs\ the\ enterprise\ engine\ in\ a\ cloud\ build;\ inert\ here=AppShield n\u00e9cessite le moteur enterprise dans une compilation cloud ; inactif ici +Device\ signing\ key=Cl\u00e9 de signature de l'appareil +P-256,\ registered\ as\ =P-256, enregistr\u00e9e sous +;\ signs\ every\ payment=; signe chaque paiement +Not\ registered\:\ payments\ that\ need\ a\ signature\ are\ refused=Non enregistr\u00e9e : les paiements n\u00e9cessitant une signature sont refus\u00e9s +Idempotency\ keys=Idempotency-Key +Each\ money\ action\ carries\ one,\ so\ a\ retry\ never\ pays\ twice=Chaque op\u00e9ration d'argent en porte une, donc une nouvelle tentative ne paie jamais deux fois +Second\ factor\ and\ passkeys=Second facteur et cl\u00e9s d'acc\u00e8s +Authenticator\ codes\ and\ passkeys=Codes d'authentification et cl\u00e9s d'acc\u00e8s +Authenticator\ codes\ (no\ passkeys\ on\ this\ platform)=Codes d'authentification (pas de cl\u00e9s d'acc\u00e8s sur cette plateforme) +Biometric\ lock=Verrouillage biom\u00e9trique +On\ start,\ on\ return\ and\ when\ idle=Au d\u00e9marrage, au retour et en cas d'inactivit\u00e9 +No\ biometrics\ enrolled\:\ the\ lock\ asks\ for\ the\ password\ instead=Aucune donn\u00e9e biom\u00e9trique enregistr\u00e9e : le verrouillage demande le mot de passe +Auto-lock=Verrouillage automatique +After\ =Apr\u00e8s +s\ idle\ or\ =s d'inactivit\u00e9 ou +s\ in\ the\ background=s en arri\u00e8re-plan +Platform\ attestation=Attestation de la plateforme +Play\ Integrity=Play Integrity +App\ Attest=App Attest +Play\ Integrity\ /\ App\ Attest\ need\ a\ device\ build=Play Integrity / App Attest n\u00e9cessitent une compilation pour appareil +Accessibility\ services=Services d'accessibilit\u00e9 +Can\ read\ the\ screen\:\ =Peuvent lire l'\u00e9cran : +None\ that\ can\ read\ the\ screen=Aucun ne peut lire l'\u00e9cran +Tapjacking=Tapjacking +Touches\ through\ overlays\ are\ dropped=Les touches \u00e0 travers les superpositions sont ignor\u00e9es +Policy\ set;\ only\ Android\ has\ overlays=Politique d\u00e9finie ; seul Android a des superpositions +Screenshot\ blocking=Blocage des captures d'\u00e9cran +Set\ on\ sensitive\ screens;\ the\ simulator\ cannot\ block\ captures=Activ\u00e9 sur les \u00e9crans sensibles ; le simulateur ne peut pas bloquer les captures +Sensitive\ screens,\ and\ the\ app\ switcher\ card=\u00c9crans sensibles et vignette du s\u00e9lecteur d'applications +Sensitive\ screens\ and\ the\ recent-apps\ card=\u00c9crans sensibles et vignette des applications r\u00e9centes +Clipboard\ clearing=Effacement du presse-papiers +Copied\ numbers\ are\ wiped\ after\ =Les num\u00e9ros copi\u00e9s sont effac\u00e9s apr\u00e8s +Code\ hardening=Durcissement du code +Level\ =Niveau +,\ mapping\ =, mappage +Declared\ =D\u00e9clar\u00e9 +;\ applied\ by\ the\ build\ server=; appliqu\u00e9 par le serveur de compilation +Jailbreak\ exit\ (iOS)=Sortie en cas de jailbreak (iOS) +App\ switcher\ blanking\ (iOS)=Masquage dans le s\u00e9lecteur d'applications (iOS) +Play\ Integrity\ (Android)=Play Integrity (Android) +Certificate\ pinning=\u00c9pinglage de certificats +Not\ applied\ to\ the\ development\ server\ (it\ has\ no\ certificate)=Non appliqu\u00e9 au serveur de d\u00e9veloppement (il n'a pas de certificat) +Placeholder\ pins\:\ put\ the\ server's\ SPKI\ digests\ in\ AppConfig.PINS=\u00c9pingles provisoires : placez les empreintes SPKI du serveur dans AppConfig.PINS +\ hosts\ pinned=\ h\u00f4tes \u00e9pingl\u00e9s +\ mismatches\ refused=\ non-concordances refus\u00e9es +Token\ and\ key\ storage=Stockage des jetons et des cl\u00e9s +Hardware-backed\ keystore=Trousseau adoss\u00e9 au mat\u00e9riel +OS\ keystore=Trousseau du syst\u00e8me +The\ simulator\ keeps\ them\ in\ a\ plain\ file=Le simulateur les conserve dans un fichier simple +No\ keystore\ on\ this\ device\:\ tokens\ are\ not\ kept\ between\ runs=Pas de trousseau sur cet appareil : les jetons ne sont pas conserv\u00e9s d'une ex\u00e9cution \u00e0 l'autre +Root\ and\ jailbreak\ checks=Contr\u00f4les root et jailbreak +Run\ on\ a\ phone;\ the\ simulator\ reports\ nothing=\u00c0 ex\u00e9cuter sur un t\u00e9l\u00e9phone ; le simulateur ne signale rien +This\ device\ looks\ modified\:\ =Cet appareil semble modifi\u00e9 : +No\ signs\ of\ root\ or\ jailbreak=Aucun signe de root ou de jailbreak +OK=OK +Last\ used\ {0}=Derni\u00e8re utilisation {0} +Add\ a\ passkey=Ajouter une cl\u00e9 d'acc\u00e8s +This\ device\ cannot\ create\ passkeys.=Cet appareil ne peut pas cr\u00e9er de cl\u00e9s d'acc\u00e8s. +Remove\ passkey=Retirer la cl\u00e9 d'acc\u00e8s +Remove\ {0}?\ It\ will\ no\ longer\ sign\ in\ to\ this\ account.=Retirer {0} ? Elle ne permettra plus de se connecter \u00e0 ce compte. +Code\ from\ your\ app=Code de votre application +Authenticator\ code=Code d'authentification +Please\ sign\ in\ again.=Veuillez vous reconnecter. +Your\ account\ is\ not\ allowed\ to\ do\ that.=Votre compte n'est pas autoris\u00e9 \u00e0 faire cela. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=Trop de tentatives. Patientez un instant et r\u00e9essayez. +Unlock\ Bastion\ first.=D\u00e9verrouillez d'abord Bastion. +PIN=PIN +Unlock\ with\ PIN=D\u00e9verrouiller avec le PIN +Not\ recognised.\ Try\ again,\ or\ use\ your\ PIN.=Non reconnu. R\u00e9essayez, ou utilisez votre PIN. +Enter\ your\ PIN\ of\ at\ least\ {0}\ digits.=Saisissez votre PIN d'au moins {0} chiffres. +Wrong\ PIN.\ One\ try\ left\ before\ the\ keys\ on\ this\ device\ are\ erased.=PIN incorrect. Il reste un essai avant l'effacement des cl\u00e9s de cet appareil. +Wrong\ PIN.\ {0}\ tries\ left.=PIN incorrect. Il reste {0} essais. +Too\ many\ wrong\ PINs.\ The\ keys\ on\ this\ device\ were\ erased.=Trop de PIN incorrects. Les cl\u00e9s de cet appareil ont \u00e9t\u00e9 effac\u00e9es. +Unlock\ PIN=PIN de d\u00e9verrouillage +Choose\ an\ unlock\ PIN=Choisissez un PIN de d\u00e9verrouillage +Set\ PIN=D\u00e9finir le PIN +Change\ PIN=Changer le PIN +This\ device\ has\ no\ biometric\ enrolled.\ Set\ an\ unlock\ PIN\ instead.=Cet appareil n'a aucune donn\u00e9e biom\u00e9trique enregistr\u00e9e. D\u00e9finissez plut\u00f4t un PIN de d\u00e9verrouillage. +Without\ a\ biometric\ or\ a\ PIN\ the\ lock\ asks\ for\ your\ password.=Sans biom\u00e9trie ni PIN, le verrouillage demande votre mot de passe. +Remove\ PIN=Retirer le PIN +Let\ your\ biometric\ unlock\ Bastion=Laissez votre biom\u00e9trie d\u00e9verrouiller Bastion +New\ PIN=Nouveau PIN +The\ same\ PIN\ again=Le m\u00eame PIN \u00e0 nouveau +The\ two\ PINs\ are\ not\ the\ same.=Les deux PIN ne sont pas identiques. +Choose\ 6\ to\ 12\ digits\ that\ are\ not\ a\ simple\ run.=Choisissez 6 \u00e0 12 chiffres qui ne forment pas une suite simple. +No\ biometric\ or\ PIN\ is\ set\ up\ on\ this\ device,\ so\ the\ way\ back\ in\ is\ your\ password.=Aucune biom\u00e9trie ni aucun PIN n'est configur\u00e9 sur cet appareil : pour y revenir, il faut votre mot de passe. +Signing\ in\ again\ sets\ this\ device\ up\ from\ the\ start\:\ its\ payment\ key,\ biometric\ and\ PIN\ are\ removed.=Se reconnecter reconfigure cet appareil depuis le d\u00e9but : sa cl\u00e9 de paiement, la biom\u00e9trie et le PIN sont supprim\u00e9s. +Bastion\ locks\ itself\ when\ you\ leave\ it.\ With\ this\ on,\ the\ phone\ keeps\ the\ key\ to\ your\ session\ and\ payments\ where\ only\ your\ biometric\ releases\ it.\ The\ device\ passcode\ is\ never\ accepted.=Bastion se verrouille tout seul quand vous le quittez. Avec cette option, le t\u00e9l\u00e9phone conserve la cl\u00e9 de votre session et de vos paiements l\u00e0 o\u00f9 seule votre biom\u00e9trie peut la lib\u00e9rer. Le code de l'appareil n'est jamais accept\u00e9. +For\ when\ a\ biometric\ is\ not\ available\ or\ you\ would\ rather\ not\ use\ one\:\ {0}\ to\ {1}\ digits,\ not\ your\ card\ PIN.\ {2}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys\ on\ this\ device.=Pour quand la biom\u00e9trie n'est pas disponible ou que vous pr\u00e9f\u00e9rez ne pas l'utiliser : {0} \u00e0 {1} chiffres, pas le PIN de votre carte. {2} essais erron\u00e9s d'affil\u00e9e effacent les cl\u00e9s de cet appareil. +Opens\ the\ lock\ when\ a\ biometric\ cannot.\ {0}\ wrong\ tries\ erase\ the\ keys\ on\ this\ device.=Ouvre le verrouillage quand la biom\u00e9trie ne le peut pas. {0} essais erron\u00e9s effacent les cl\u00e9s de cet appareil. +Unlock\ first.=D\u00e9verrouillez d'abord. +This\ device\ could\ not\ keep\ a\ biometric-bound\ key.=Cet appareil n'a pas pu conserver une cl\u00e9 li\u00e9e \u00e0 la biom\u00e9trie. +This\ device\ has\ no\ biometric-bound\ key\ store.=Cet appareil n'a pas de stockage de cl\u00e9s li\u00e9 \u00e0 la biom\u00e9trie. +Biometric\ setup\ was\ cancelled.=La configuration de la biom\u00e9trie a \u00e9t\u00e9 annul\u00e9e. +No\ PIN\ is\ set\ on\ this\ device.=Aucun PIN n'est d\u00e9fini sur cet appareil. +The\ PIN\ vault\ is\ damaged.=Le coffre du PIN est endommag\u00e9. +The\ PIN\ vault\ could\ not\ be\ read.=Le coffre du PIN n'a pas pu \u00eatre lu. +The\ PIN\ could\ not\ be\ saved.=Le PIN n'a pas pu \u00eatre enregistr\u00e9. +The\ old\ PIN\ could\ not\ be\ removed.=L'ancien PIN n'a pas pu \u00eatre retir\u00e9. +This\ device\ has\ no\ secure\ storage\ for\ the\ PIN.=Cet appareil n'a pas de stockage s\u00e9curis\u00e9 pour le PIN. +App\ key=Cl\u00e9 de l'application +Refresh\ token=Jeton d'actualisation +App\ lock=Verrouillage de l'application +Biometrics=Biom\u00e9trie +A\ signed-off\ copy\ for\ an\ external\ auditor\:\ the\ file,\ who\ made\ it\ and\ when,\ and\ its\ SHA-256\ so\ it\ can\ be\ checked\ later.=Une copie valid\u00e9e pour un auditeur externe : le fichier, qui l'a produit et quand, et son SHA-256 pour le v\u00e9rifier plus tard. +Access\ log=Journal des acc\u00e8s +Action=Action +Actor=Acteur +Actual=R\u00e9el +Adjustment=Ajustement +All\ entries=Toutes les \u00e9critures +Amount\ (negative\ to\ debit)=Montant (n\u00e9gatif pour d\u00e9biter) +Approvals=Approbations +Approve=Approuver +Approved=Approuv\u00e9 +As\ of\ {0}=Au {0} +Asset=Actif +Assets=Actifs +Assets\ \=\ liabilities\ +\ equity=Actifs = passifs + capitaux propres +Audit=Audit +Audit\ chain=Cha\u00eene d'audit +Audit\ trail=Piste d'audit +Audit\ trail\ of\ this\ entry=Piste d'audit de cette \u00e9criture +Awaiting\ approval=En attente d'approbation +Balance=Solde +Balance\ after=Solde apr\u00e8s +Balance\ sheet=Bilan +Balance,\ 30\ days=Solde, 30 jours +Balanced=\u00c9quilibr\u00e9 +Breaks=\u00c9carts +Broken\ at\ \#{0}=Rompue au n\u00b0{0} +CSV=CSV +Capital=Capital +Class=Classe +Close\ period=Cl\u00f4turer la p\u00e9riode +Close\ the\ books\ through\ now=Cl\u00f4turer les comptes jusqu'\u00e0 maintenant +Closed\ at=Cl\u00f4tur\u00e9 le +Closed\ by=Cl\u00f4tur\u00e9 par +Closed\ through=Cl\u00f4tur\u00e9 jusqu'au +Closing\ a\ period\ moves\ revenue\ and\ expense\ into\ retained\ earnings\ and\ locks\ every\ entry\ up\ to\ its\ date.=Cl\u00f4turer une p\u00e9riode transf\u00e8re produits et charges en report \u00e0 nouveau et verrouille toutes les \u00e9critures jusqu'\u00e0 sa date. +Closing\ balance=Solde de cl\u00f4ture +Closing\ entry=\u00c9criture de cl\u00f4ture +Control=Contr\u00f4le +Could\ not\ save\ the\ file.=Impossible d'enregistrer le fichier. +Credit\ (Cr)=Cr\u00e9dit (Cr) +Credit\ to\ your\ account=Cr\u00e9dit sur votre compte +Crypto\ withdrawal=Retrait crypto +Date=Date +Debit\ (Dr)=D\u00e9bit (Dr) +Debit\ from\ your\ account=D\u00e9bit de votre compte +Decide=D\u00e9cider +Description=Libell\u00e9 +Difference=\u00c9cart +Dollar\ balance,\ 30\ days=Solde en dollars, 30 jours +Dollar\ volume,\ 30\ days=Volume en dollars, 30 jours +Each\ control\ account\ against\ the\ sum\ of\ its\ subledger.\ A\ difference\ is\ a\ break\ and\ is\ shown\ in\ red.=Chaque compte collectif face \u00e0 la somme de son grand livre auxiliaire. Un \u00e9cart est affich\u00e9 en rouge. +Each\ event's\ hash\ covers\ its\ own\ fields\ and\ the\ hash\ before\ it,\ so\ changing\ any\ row\ breaks\ every\ link\ after\ it.=Le hash de chaque \u00e9v\u00e9nement couvre ses champs et le hash pr\u00e9c\u00e9dent : modifier une ligne rompt tous les maillons suivants. +Entries=\u00c9critures +Entry=\u00c9criture +Entry\ id,\ account\ id\ or\ e-mail=ID d'\u00e9criture, de compte ou e-mail +Equity=Capitaux propres +Every\ control\ account\ agrees\ with\ its\ subledger.=Chaque compte collectif concorde avec son auxiliaire. +Every\ entry,\ newest\ first.\ Debits\ and\ credits\ of\ an\ entry\ sum\ to\ the\ same\ amount\ in\ each\ currency.\ Tap\ an\ entry\ for\ its\ legs.=Toutes les \u00e9critures, la plus r\u00e9cente d'abord. D\u00e9bits et cr\u00e9dits d'une \u00e9criture s'\u00e9quilibrent dans chaque devise. Touchez une \u00e9criture pour ses lignes. +Every\ event\ that\ names\ the\ subject,\ oldest\ first.=Tous les \u00e9v\u00e9nements qui citent le sujet, le plus ancien d'abord. +Every\ read\ of\ the\ books\ and\ the\ audit\ log\ by\ staff\ is\ itself\ an\ audit\ event.=Chaque lecture des comptes et du journal d'audit par le personnel est elle-m\u00eame un \u00e9v\u00e9nement d'audit. +Evidence=Pi\u00e8ces justificatives +Expected=Attendu +Expenses=Charges +General\ ledger=Grand livre +Hash=Hash +Head=T\u00eate +Head\ hash=Hash de t\u00eate +In\ dollars=En dollars +Income\ statement=Compte de r\u00e9sultat +Initiated\ by=Initi\u00e9 par +Integrity\ checks=Contr\u00f4les d'int\u00e9grit\u00e9 +JSON=JSON +Journal=Journal +Journal\ entries=\u00c9critures au journal +Journal\ entries\ a\ day=\u00c9critures par jour +Journal\ entry\ {0}=\u00c9criture {0} +Ledger\ entry=\u00c9criture comptable +Liabilities=Passifs +Links\ checked=Maillons v\u00e9rifi\u00e9s +Made\ by\ {0},\ {1}=Produit par {0}, {1} +Maker=Initiateur +Maker-checker\:\ an\ adjustment\ or\ a\ reversal\ raised\ by\ one\ administrator\ is\ posted\ only\ when\ another\ approves\ it.=Double contr\u00f4le : un ajustement ou une contre-passation cr\u00e9\u00e9 par un administrateur n'est comptabilis\u00e9 qu'apr\u00e8s l'approbation d'un autre. +Memo=Libell\u00e9 +Money\ in\ and\ out,\ 14\ days=Entr\u00e9es et sorties, 14 jours +Net\ income=R\u00e9sultat net +No\ breaks=Aucun \u00e9cart +No\ entries\ yet.=Aucune \u00e9criture pour l'instant. +No\ events.=Aucun \u00e9v\u00e9nement. +No\ period\ has\ been\ closed\ yet.=Aucune p\u00e9riode n'a encore \u00e9t\u00e9 cl\u00f4tur\u00e9e. +No\ postings\ to\ this\ account\ in\ this\ currency.=Aucune \u00e9criture sur ce compte dans cette devise. +Nothing\ in\ this\ section.=Rien dans cette section. +Nothing\ waiting.=Rien en attente. +Opening\ balance=Solde d'ouverture +Order\ hold=Blocage d'ordre +Order\ release=Lib\u00e9ration d'ordre +Out\ of\ balance=D\u00e9s\u00e9quilibr\u00e9 +Overview=Vue d'ensemble +Owner=Titulaire +Pending=En attente +Period=P\u00e9riode +Period\ close=Cl\u00f4ture de p\u00e9riode +Posted=Comptabilis\u00e9 +Posting=Ligne d'\u00e9criture +Previous\ hash=Hash pr\u00e9c\u00e9dent +Price,\ 24\ hours=Prix, 24 heures +Problem=Probl\u00e8me +Raise\ an\ adjustment=Cr\u00e9er un ajustement +Raised=Cr\u00e9\u00e9 +Read-only=Lecture seule +Reason=Motif +Reconciliation=Rapprochement +Reject=Rejeter +Rejected=Rejet\u00e9 +Request\ adjustment=Demander l'ajustement +Request\ reversal=Demander la contre-passation +Revenue=Produits +Reversal=Contre-passation +Reverse\ entry\ {0}=Contre-passer l'\u00e9criture {0} +Reverse\ this\ entry=Contre-passer cette \u00e9criture +Reversed\ by=Contre-pass\u00e9 par +Reverses=Contre-passe +SHA-256=SHA-256 +Saved=Enregistr\u00e9 +Sent\ for\ approval.=Envoy\u00e9 pour approbation. +Show\ as\ a\ T-account=Afficher en compte en T +Show\ trail=Afficher la piste +Side=Sens +Staff\ member=Membre du personnel +Statement=Relev\u00e9 +Status=Statut +Subject=Sujet +Subledger=Auxiliaire +The\ other\ side\ of\ this\ entry\ is\ in\ the\ bank's\ books;\ every\ entry's\ debits\ equal\ its\ credits.=L'autre c\u00f4t\u00e9 de cette \u00e9criture est dans les comptes de la banque ; les d\u00e9bits de chaque \u00e9criture \u00e9galent ses cr\u00e9dits. +Time=Heure +Total=Total +Total\ assets=Total des actifs +Total\ credits=Total des cr\u00e9dits +Total\ debits=Total des d\u00e9bits +Transaction=Op\u00e9ration +Transfer=Virement +Trial\ balance=Balance de v\u00e9rification +Type=Type +Unavailable=Indisponible +Valued\ in\ dollars=Valoris\u00e9 en dollars +Volume\ ({0})=Volume ({0}) +Where\ your\ money\ is=O\u00f9 est votre argent +Withdrawal=Retrait +Your\ password=Votre mot de passe +credit=cr\u00e9dit +debit=d\u00e9bit +{0}\ account,\ normal\ balance\ {1}.=Compte de {0}, solde normal {1}. +{0}\ breaks={0} \u00e9carts +{0}\ raised\ by\ {1}={0} cr\u00e9\u00e9 par {1} +{0}\ to\ {1}={0} au {1} +Showing\ the\ latest\ {0}\ of\ {1}\ postings.=Affichage des {0} derni\u00e8res \u00e9critures sur {1}. +Cash\ in\ dollar\ accounts,\ 30\ days=Liquidit\u00e9s en comptes en dollars, 30 jours +error.account-already-has-authenticator-app=Ce compte a d\u00e9j\u00e0 une application d'authentification +error.account-already-has-card=Ce compte a d\u00e9j\u00e0 une carte +error.account-frozen=Ce compte est gel\u00e9 +error.add-authenticator-app=Ajoutez d'abord une application d'authentification +error.address-already-list=Cette adresse est d\u00e9j\u00e0 dans votre liste +error.address-new-used=Cette adresse est nouvelle : elle sera utilisable \u00e0 partir du {0} +error.address-wrong-asset=Cette adresse est pour {0} +error.administrators=R\u00e9serv\u00e9 aux administrateurs +error.amount-cannot-zero=Un montant ne peut pas \u00eatre nul +error.amount-more-than-zero=Le montant doit \u00eatre sup\u00e9rieur \u00e0 z\u00e9ro +error.amount-too-large=Ce montant est trop \u00e9lev\u00e9 +error.both-accounts-hold-same-currency=Les deux comptes doivent \u00eatre dans la m\u00eame devise. Utilisez l'\u00e9change pour convertir. +error.card-frozen=Cette carte est gel\u00e9e +error.card-limit-range=Le plafond d'une carte va de 0 \u00e0 {0} +error.cards-issued-checking-accounts=Les cartes sont \u00e9mises sur les comptes courants +error.choose-password-least-characters=Choisissez un mot de passe d'au moins {0} caract\u00e8res +error.choose-two-different-accounts=Choisissez deux comptes diff\u00e9rents +error.close-account-before-opening-another=Fermez un compte avant d'en ouvrir un autre +error.code-not-app-shows=Ce code n'est pas celui qu'affiche votre application +error.coin-withdrawn-address=Seul un coin peut \u00eatre retir\u00e9 vers une adresse +error.coins-withdrawn-wallet=Les coins se retirent depuis un portefeuille +error.crypto-held-wallet-fiat-account=Les cryptos sont d\u00e9tenues dans un portefeuille, la monnaie fiat dans un compte +error.customers-hold-accounts=Seuls les clients d\u00e9tiennent des comptes +error.device-not-attested=Cet appareil n'est pas attest\u00e9, ou est root\u00e9 : il ne peut d\u00e9placer de l'argent qu'entre vos propres comptes +error.e-mail-already-has-account=Cet e-mail a d\u00e9j\u00e0 un compte +error.enter-password-add-device-turn=Saisissez votre mot de passe pour ajouter un appareil, ou activez une application d'authentification +error.account-locked=Trop de mots de passe incorrects : ce compte est bloqu\u00e9 pendant un moment +error.sign-or-enter-password-revoke=Approuvez-le sur un autre appareil ou saisissez votre mot de passe +error.enter-password-add-app=Saisissez votre mot de passe pour ajouter une application d'authentification +error.entry-needs-two-sides=Une \u00e9criture a besoin de deux c\u00f4t\u00e9s +error.idempotency-key-reused-order=Cette Idempotency-Key a d\u00e9j\u00e0 servi pour un autre ordre +error.idempotency-key-reused=Cette Idempotency-Key a d\u00e9j\u00e0 servi pour une autre requ\u00eate +error.insufficient-funds=Fonds insuffisants +error.key-ec-p-256-public=La cl\u00e9 doit \u00eatre une cl\u00e9 publique EC P-256 +error.limit-order-names-no-budget=Un ordre \u00e0 cours limit\u00e9 n'indique pas de budget +error.limit-order-needs-price=Un ordre \u00e0 cours limit\u00e9 n\u00e9cessite un prix +error.market-buy-names-budget=Un achat au march\u00e9 n'indique qu'un budget +error.market-sell-names-quantity=Une vente au march\u00e9 n'indique qu'une quantit\u00e9 +error.more-than-crypto-movement-may=C'est plus qu'un mouvement crypto ne peut d\u00e9placer +error.more-than-payment-may-move=C'est plus qu'un paiement ne peut d\u00e9placer +error.move-money-between-own-accounts=D\u00e9placez plut\u00f4t l'argent entre vos propres comptes +error.name-account-pay-either-account=Indiquez le compte \u00e0 d\u00e9biter, ainsi qu'un compte ou un b\u00e9n\u00e9ficiaire \u00e0 payer +error.name-payee=Indiquez le b\u00e9n\u00e9ficiaire +error.name-too-long=Ce nom est trop long +error.needs-device-signature=Cela n\u00e9cessite une signature de votre appareil : {0} +error.new-payee-receive-until-their=Un nouveau b\u00e9n\u00e9ficiaire ne peut recevoir que {0} jusqu'\u00e0 la fin de son d\u00e9lai d'attente +error.no-customer-has-address=Aucun client n'a cette adresse +error.no-such-account=Ce compte n'existe pas +error.no-such-address=Cette adresse n'existe pas +error.no-such-asset=Cet actif n'existe pas +error.no-such-card=Cette carte n'existe pas +error.no-such-device=Cet appareil n'existe pas +error.no-such-kind-account=Ce type de compte n'existe pas +error.no-such-market=Ce march\u00e9 n'existe pas +error.no-such-order=Cet ordre n'existe pas +error.no-such-passkey=Cette cl\u00e9 d'acc\u00e8s n'existe pas +error.no-such-payee=Ce b\u00e9n\u00e9ficiaire n'existe pas +error.not-address=Ce n'est pas une adresse +error.not-current-password=Ce n'est pas votre mot de passe actuel +error.not-e-mail-address=Ce n'est pas une adresse e-mail +error.not-found=Introuvable +error.nothing-register=Rien \u00e0 enregistrer +error.open-checking-first=Ouvrez d'abord un compte courant en {0} +error.open-checking-savings-wallet-account=Ouvrez un compte CHECKING, SAVINGS ou WALLET +error.open-wallet-first=Ouvrez d'abord un portefeuille {0} +error.order-already-status=Cet ordre est d\u00e9j\u00e0 {0} +error.order-buy-sell=Un ordre est un BUY ou un SELL +error.order-limit-market=Un ordre est LIMIT ou MARKET +error.order-too-large=Cet ordre est trop important +error.over-card-daily-limit=Cela d\u00e9passe le plafond quotidien de la carte +error.password-cannot-e-mail-address=Votre mot de passe ne peut pas \u00eatre votre adresse e-mail +error.password-too-long=Ce mot de passe est trop long +error.payee-already-list=Ce b\u00e9n\u00e9ficiaire est d\u00e9j\u00e0 dans votre liste +error.payee-cannot-receive=Ce b\u00e9n\u00e9ficiaire ne peut pas recevoir {0} +error.quantity-whole-number-unit-lots=La quantit\u00e9 doit \u00eatre un nombre entier de lots de {0} unit\u00e9s +error.remove-address-before-adding-another=Retirez une adresse avant d'en ajouter une autre +error.remove-device-before-adding-another=Retirez un appareil avant d'en ajouter un autre +error.remove-payee-before-adding-another=Retirez un b\u00e9n\u00e9ficiaire avant d'en ajouter un autre +error.say-which-account-open=Indiquez quel compte ouvrir +error.send-address-amount=Envoyez l'adresse et le montant +error.send-address=Envoyez l'adresse +error.send-device-public-key=Envoyez la cl\u00e9 publique de l'appareil +error.send-idempotency-key-16-80=Envoyez une Idempotency-Key de 16 \u00e0 80 caract\u00e8res +error.send-limit=Envoyez le plafond +error.send-order=Envoyez l'ordre +error.sign-again-add-device-sign=Reconnectez-vous pour ajouter un appareil : la connexion doit dater de quelques minutes +error.sign-in-first=Connectez-vous d'abord +error.tell-us-name=Indiquez-nous votre nom +error.there-no-price=Il n'y a pas de prix pour {0} +error.withdraw-address-list=Ne retirez que vers une adresse de votre liste +error.would-pass-crypto-limit-day=Cela d\u00e9passerait votre plafond crypto du jour +error.would-pass-limit-day=Cela d\u00e9passerait votre plafond du jour +Language=Langue +Bastion\ cannot\ start=Bastion ne peut pas d\u00e9marrer +The\ network\ protection\ could\ not\ be\ installed,\ so\ Bastion\ will\ not\ connect.\ Reinstall\ the\ app.=La protection r\u00e9seau n'a pas pu \u00eatre install\u00e9e, Bastion ne se connectera donc pas. R\u00e9installez l'application. +A\ payee=Un b\u00e9n\u00e9ficiaire +A\ vault\ opened\ by\ the\ PIN\ (PBKDF2,\ {0}\ rounds,\ peppered\ from\ the\ OS\ store);\ {1}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys=Un coffre ouvert par le PIN (PBKDF2, {0} tours, poivr\u00e9 depuis le magasin du syst\u00e8me) ; {1} essais erron\u00e9s d'affil\u00e9e effacent les cl\u00e9s +A\ vault\ opened\ by\ the\ PIN\ (PBKDF2,\ {0}\ rounds,\ peppered\ from\ the\ OS\ store);\ {1}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys.\ Wrong\ tries\ so\ far\:\ {2}=Un coffre ouvert par le PIN (PBKDF2, {0} tours, poivr\u00e9 depuis le magasin du syst\u00e8me) ; {1} essais erron\u00e9s d'affil\u00e9e effacent les cl\u00e9s. Essais erron\u00e9s jusqu'ici : {2} +Active=Active +After\ {0}s\ idle\ or\ {1}s\ in\ the\ background=Apr\u00e8s {0} s d'inactivit\u00e9 ou {1} s en arri\u00e8re-plan +AppShield\ engine\ {0}\:\ {1}=Moteur AppShield {0} : {1} +Available;\ turn\ it\ on\ in\ Settings=Disponible ; activez-le dans R\u00e9glages +Biometric\ copy\ simulated\:\ a\ file\ gated\ by\ the\ simulator's\ Biometric\ Simulation\ menu=Copie biom\u00e9trique simul\u00e9e : un fichier prot\u00e9g\u00e9 par le menu Biometric Simulation du simulateur +Can\ read\ the\ screen\:\ {0}=Peuvent lire l'\u00e9cran : {0} +Cash\ withdrawal=Retrait d'esp\u00e8ces +Cloud\ build=Compilation dans le cloud +Copied\ numbers\ are\ wiped\ after\ {0}s=Les num\u00e9ros copi\u00e9s sont effac\u00e9s apr\u00e8s {0} s +Declared\ {0};\ applied\ by\ the\ build\ server=D\u00e9clar\u00e9 {0} ; appliqu\u00e9 par le serveur de compilation +Deposit=D\u00e9p\u00f4t +Development\ server\ {0}\ is\ exempt\:\ plain\ HTTP\ to\ this\ machine\ only=Le serveur de d\u00e9veloppement {0} est exempt\u00e9 : HTTP simple vers cette machine uniquement +Enter\ an\ amount,\ like\ {0}.=Saisissez un montant, par exemple {0}. +Every\ entry\ balances\ and\ every\ balance\ matches\ its\ postings.=Chaque \u00e9criture est \u00e9quilibr\u00e9e et chaque solde correspond \u00e0 ses mouvements. +Every\ request\ is\ https;\ http\ is\ refused\ ({0}\ refused)=Chaque requ\u00eate est en https ; http est refus\u00e9 ({0} refus\u00e9es) +Keychain\ item\ with\ biometric\ access\ control\ (current\ set)\:\ released\ only\ after\ Face\ ID\ or\ Touch\ ID=\u00c9l\u00e9ment du trousseau avec contr\u00f4le d'acc\u00e8s biom\u00e9trique (ensemble actuel) : lib\u00e9r\u00e9 seulement apr\u00e8s Face ID ou Touch ID +Keystore\ key\ that\ requires\ user\ authentication\:\ released\ only\ after\ a\ strong\ biometric=Cl\u00e9 du Keystore exigeant une authentification : lib\u00e9r\u00e9e seulement apr\u00e8s une biom\u00e9trie forte +Level\ {0},\ mapping\ {1}=Niveau {0}, mappage {1} +Limit=Limite +Market=March\u00e9 +My\ accounts=Mes comptes +No\ such\ account=Ce compte n'existe pas +None\ enrolled\:\ the\ PIN\ or\ the\ password\ unlocks\ instead=Aucune enregistr\u00e9e : le PIN ou le mot de passe d\u00e9verrouille \u00e0 la place +Not\ in\ simulator=Pas dans le simulateur +Not\ set\:\ Settings,\ Unlock\ PIN=Non d\u00e9fini : R\u00e9glages, PIN de d\u00e9verrouillage +On\ start,\ on\ return\ and\ when\ idle\:\ PIN=Au d\u00e9marrage, au retour et en inactivit\u00e9 : PIN +On\ start,\ on\ return\ and\ when\ idle\:\ biometric=Au d\u00e9marrage, au retour et en inactivit\u00e9 : biom\u00e9trie +On\ start,\ on\ return\ and\ when\ idle\:\ biometric,\ then\ PIN=Au d\u00e9marrage, au retour et en inactivit\u00e9 : biom\u00e9trie, puis PIN +On\ start,\ on\ return\ and\ when\ idle\:\ the\ password=Au d\u00e9marrage, au retour et en inactivit\u00e9 : le mot de passe +Only\ {0}=Seulement {0} +Opened\ only\ by\ the\ unlock\ PIN=Ouvert uniquement par le PIN de d\u00e9verrouillage +Opened\ without\ asking\:\ set\ up\ a\ biometric\ or\ a\ PIN=Ouvert sans demander : configurez une biom\u00e9trie ou un PIN +P-256,\ registered\ as\ {0};\ signs\ every\ payment=P-256, enregistr\u00e9e sous {0} ; signe chaque paiement +Released\ by\ the\ platform\ after\ a\ biometric=Lib\u00e9r\u00e9e par la plateforme apr\u00e8s une biom\u00e9trie +Sealed\ under\ the\ app\ key,\ which\ opens\ without\ asking\ until\ a\ biometric\ or\ PIN\ is\ set\ up=Scell\u00e9 sous la cl\u00e9 de l'app, qui s'ouvre sans demander tant qu'aucune biom\u00e9trie ni PIN n'est configur\u00e9 +Sealed\ under\ the\ app\ key\:\ unreadable\ until\ the\ lock\ is\ opened=Scell\u00e9 sous la cl\u00e9 de l'app : illisible tant que le verrou n'est pas ouvert +The\ audit\ chain\ is\ intact.=La cha\u00eene d'audit est intacte. +The\ PIN\ could\ not\ be\ removed.=Le PIN n'a pas pu \u00eatre supprim\u00e9. +This\ device\ looks\ modified\:\ {0}.\ The\ bank\ will\ not\ move\ money\ from\ it.=Cet appareil semble modifi\u00e9 : {0}. La banque n'effectuera aucun mouvement depuis celui-ci. +This\ platform\ has\ no\ biometric-bound\ store\:\ set\ up\ a\ PIN=Cette plateforme n'a pas de stockage li\u00e9 \u00e0 la biom\u00e9trie : configurez un PIN +Try\ again=R\u00e9essayer +Unlock\ with\ a\ biometric\ is\ on=Le d\u00e9verrouillage biom\u00e9trique est activ\u00e9 +Warning=Avertissement +android.playIntegrity\={0}=android.playIntegrity={0} +ios.blockScreenshotsOnEnterBackground\={0}=ios.blockScreenshotsOnEnterBackground={0} +ios.detectJailbreak\={0}=ios.detectJailbreak={0} +{0}\ hosts\ pinned={0} h\u00f4tes \u00e9pingl\u00e9s +{0}\ hosts\ pinned,\ {1}\ mismatches\ refused={0} h\u00f4tes \u00e9pingl\u00e9s, {1} non-concordances refus\u00e9es +{0}\ hosts,\ version\ {1}={0} h\u00f4tes, version {1} +{0}\ hosts,\ version\ {1}\ (refresh\ due)={0} h\u00f4tes, version {1} (mise \u00e0 jour due) +error.price-outside-collar=Le prix doit rester \u00e0 {0} % du prix de r\u00e9f\u00e9rence +error.no-such-entry=Aucune \u00e9criture de ce type +error.entry-cannot-reversed=Cette \u00e9criture ne peut pas \u00eatre contrepass\u00e9e +error.entry-already-reversed=Cette \u00e9criture a d\u00e9j\u00e0 \u00e9t\u00e9 contrepass\u00e9e +error.period-closed=Cette date appartient \u00e0 une p\u00e9riode cl\u00f4tur\u00e9e +error.period-end-past-last-close=Une p\u00e9riode doit se terminer dans le pass\u00e9, apr\u00e8s la derni\u00e8re cl\u00f4tur\u00e9e +error.four-eyes-second-administrator=Un second administrateur doit approuver ceci +error.approval-not-pending=Cette demande n'attend plus d'approbation +error.staff-only=R\u00e9serv\u00e9 au personnel +error.enter-password-confirm=Saisissez votre mot de passe pour confirmer +error.not-number=Ce n'est pas un nombre +error.name-entity=Indiquez l'entit\u00e9 \u00e0 tracer +error.format-csv-json=Le format est csv ou json +Opening=Ouverture +Closing=Cl\u00f4ture +Posted.=Comptabilis\u00e9. +Above\ the\ approval\ threshold,\ a\ second\ administrator\ must\ approve\ it.=Au-del\u00e0 du seuil d'approbation, un second administrateur doit l'approuver. +Only\ the\ latest\ {0}\ postings\ are\ listed;\ the\ totals\ and\ balances\ cover\ them\ all.=Seules les {0} derni\u00e8res \u00e9critures sont list\u00e9es ; les totaux et soldes les couvrent toutes. +Receipt=Re\u00e7u +Completed=Termin\u00e9e +Fee=Frais +Category=Cat\u00e9gorie +Show=Afficher +Columns=Colonnes +Card=Carte +Other=Autre +Card\ details=D\u00e9tails de la carte +Virtual\ debit=D\u00e9bit virtuelle +Expires=Expire +Cardholder=Titulaire +{0}\ used\ today,\ {1}\ left={0} utilis\u00e9s aujourd'hui, {1} restants +Recent\ card\ payments=Paiements r\u00e9cents par carte +This\ card\ has\ not\ been\ used\ yet.=Cette carte n'a pas encore \u00e9t\u00e9 utilis\u00e9e. +Account\ ID=Identifiant du compte +No\ movements\ in\ this\ period.=Aucun mouvement sur cette p\u00e9riode. +Card\ ID=Identifiant de la carte +Entry\ id=Identifiant de l'\u00e9criture +Currency=Devise +Books=Livres +All\ accounts=Tous les comptes +As\ of=Au +File=Fichier +Made\ by=Cr\u00e9\u00e9 par +Generated=G\u00e9n\u00e9r\u00e9 +Print=Imprimer +Printing\ is\ not\ available\ on\ this\ device.=L'impression n'est pas disponible sur cet appareil. +Could\ not\ print\:\ {0}=Impossible d'imprimer : {0} +Sent\ to\ the\ printer.=Envoy\u00e9 \u00e0 l'imprimante. +Page\ {0}\ of\ {1}=Page {0} sur {1} +{0}\ (continued)={0} (suite) +Device\ key\ ID=Identifiant de la cl\u00e9 de l'appareil +Passkey\ ID=Identifiant de la cl\u00e9 d'acc\u00e8s +Copy\ {0}=Copier {0} +Copied=Copi\u00e9 +Or\ sign\ in\ with\ your\ e-mail\ and\ password.\ An\ account\ with\ a\ passkey\ takes\ its\ password\ only\ with\ a\ recovery\ code.=Ou connectez-vous avec votre e-mail et votre mot de passe. Un compte dot\u00e9 d'une cl\u00e9 d'acc\u00e8s n'accepte son mot de passe qu'avec un code de r\u00e9cup\u00e9ration. +This\ account\ signs\ in\ with\ its\ passkey.\ Use\ the\ passkey,\ or\ ask\ the\ bank\ to\ help\ you\ recover\ the\ account.=Ce compte se connecte avec sa cl\u00e9 d'acc\u00e8s. Utilisez-la, ou demandez \u00e0 la banque de vous aider \u00e0 r\u00e9cup\u00e9rer le compte. +This\ account\ signs\ in\ with\ its\ passkey.\ Without\ it,\ enter\ one\ of\ your\ recovery\ codes.=Ce compte se connecte avec sa cl\u00e9 d'acc\u00e8s. Sans elle, saisissez l'un de vos codes de r\u00e9cup\u00e9ration. +Custody=Conservation +Held\ on\ chain\ {0}=Sur la cha\u00eene {0} +Owed\ to\ customers\ {0}=D\u00fb aux clients {0} +Hot\ wallet=Portefeuille chaud +In\ transit=En transit +Withdrawals\ not\ yet\ confirmed=Retraits pas encore confirm\u00e9s +Receive\ crypto=Recevoir des cryptos +Chain\ {0}=Cha\u00eene {0} +Your\ {0}\ address=Votre adresse {0} +Credited\ after\ {0}\ confirmations,\ usually\ a\ few\ minutes.=Cr\u00e9dit\u00e9 apr\u00e8s {0} confirmations, en g\u00e9n\u00e9ral quelques minutes. +On\ the\ chain=Sur la cha\u00eene +Nothing\ has\ crossed\ the\ chain\ yet.=Rien n'est encore pass\u00e9 par la cha\u00eene. +To\ {0}=Vers {0} +Final=D\u00e9finitif +Failed=\u00c9chec +Confirming=Confirmation en cours +Sending=Envoi en cours +Set\ up\ a\ second\ factor=Configurer un second facteur +Open\ Settings=Ouvrir les r\u00e9glages +error.too-many-new-addresses=Vous avez ajout\u00e9 autant d'adresses qu'une journ\u00e9e le permet ; r\u00e9essayez demain +error.second-factor-add-device=Configurez une application d'authentification ou une cl\u00e9 d'acc\u00e8s, puis connectez-vous avec pour ajouter un appareil +error.too-many-new-payees=Vous avez ajout\u00e9 autant de b\u00e9n\u00e9ficiaires qu'une journ\u00e9e le permet ; r\u00e9essayez demain diff --git a/scripts/bastion/common/src/main/l10n/Bundle_he.properties b/scripts/bastion/common/src/main/l10n/Bundle_he.properties new file mode 100644 index 00000000000..8ed66bce0d6 --- /dev/null +++ b/scripts/bastion/common/src/main/l10n/Bundle_he.properties @@ -0,0 +1,745 @@ +# Bastion in Hebrew. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +# These translations were generated automatically as a sample and have not +# been reviewed by a native speaker. Review them before shipping. +@rtl=true +language.name=\u05e2\u05d1\u05e8\u05d9\u05ea + +Account=\u05d7\u05e9\u05d1\u05d5\u05df +Frozen=\u05de\u05d5\u05e7\u05e4\u05d0 +Move\ money=\u05d4\u05e2\u05d1\u05e8\u05ea \u05db\u05e1\u05e3 +Send=\u05e9\u05dc\u05d9\u05d7\u05d4 +Add\ money=\u05d4\u05d5\u05e1\u05e4\u05ea \u05db\u05e1\u05e3 +Withdraw=\u05de\u05e9\u05d9\u05db\u05d4 +Order\ a\ card=\u05d4\u05d6\u05de\u05e0\u05ea \u05db\u05e8\u05d8\u05d9\u05e1 +No\ card=\u05d0\u05d9\u05df \u05db\u05e8\u05d8\u05d9\u05e1 +History=\u05d4\u05d9\u05e1\u05d8\u05d5\u05e8\u05d9\u05d4 +Nothing\ has\ moved\ in\ this\ account\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05d4\u05d9\u05d9\u05ea\u05d4 \u05ea\u05e0\u05d5\u05e2\u05d4 \u05d1\u05d7\u05e9\u05d1\u05d5\u05df \u05d4\u05d6\u05d4. +Money\ in=\u05db\u05e1\u05e3 \u05e0\u05db\u05e0\u05e1 +Money\ out=\u05db\u05e1\u05e3 \u05d9\u05d5\u05e6\u05d0 +Amount=\u05e1\u05db\u05d5\u05dd +Add=\u05d4\u05d5\u05e1\u05e4\u05d4 +Withdraw\ cash=\u05de\u05e9\u05d9\u05db\u05ea \u05de\u05d6\u05d5\u05de\u05df +Cancel=\u05d1\u05d9\u05d8\u05d5\u05dc +Not\ done=\u05dc\u05d0 \u05d1\u05d5\u05e6\u05e2 +Administration=\u05e0\u05d9\u05d4\u05d5\u05dc +Administrators\ only.=\u05dc\u05de\u05e0\u05d4\u05dc\u05d9\u05dd \u05d1\u05dc\u05d1\u05d3. +Recomputed\ from\ the\ stored\ rows\ each\ time\ you\ ask.=\u05de\u05d7\u05d5\u05e9\u05d1 \u05de\u05d7\u05d3\u05e9 \u05de\u05d4\u05e9\u05d5\u05e8\u05d5\u05ea \u05d4\u05e9\u05de\u05d5\u05e8\u05d5\u05ea \u05d1\u05db\u05dc \u05e4\u05e2\u05dd \u05e9\u05de\u05d1\u05e7\u05e9\u05d9\u05dd. +Verify\ the\ audit\ log=\u05d0\u05d9\u05de\u05d5\u05ea \u05d9\u05d5\u05de\u05df \u05d4\u05d1\u05d9\u05e7\u05d5\u05e8\u05ea +Verify\ the\ ledger=\u05d0\u05d9\u05de\u05d5\u05ea \u05e4\u05e0\u05e7\u05e1 \u05d4\u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea +Audit\ log=\u05d9\u05d5\u05de\u05df \u05d1\u05d9\u05e7\u05d5\u05e8\u05ea +Ledger=\u05e4\u05e0\u05e7\u05e1 \u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea +Intact=\u05ea\u05e7\u05d9\u05df +Problems\ found=\u05e0\u05de\u05e6\u05d0\u05d5 \u05d1\u05e2\u05d9\u05d5\u05ea +Cards=\u05db\u05e8\u05d8\u05d9\u05e1\u05d9\u05dd +No\ cards\ yet.\ Order\ one\ from\ a\ checking\ account.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05db\u05e8\u05d8\u05d9\u05e1\u05d9\u05dd. \u05d4\u05d6\u05de\u05d9\u05e0\u05d5 \u05db\u05e8\u05d8\u05d9\u05e1 \u05de\u05d7\u05e9\u05d1\u05d5\u05df \u05e2\u05d5"\u05e9. +Bastion=Bastion +Spends\ up\ to\ {0}\ a\ day=\u05d4\u05d5\u05e6\u05d0\u05d4 \u05e2\u05d3 {0} \u05d1\u05d9\u05d5\u05dd +Unfreeze=\u05d1\u05d9\u05d8\u05d5\u05dc \u05d4\u05e7\u05e4\u05d0\u05d4 +Freeze=\u05d4\u05e7\u05e4\u05d0\u05d4 +Not\ changed=\u05dc\u05d0 \u05e9\u05d5\u05e0\u05d4 +Daily\ limit=\u05de\u05d2\u05d1\u05dc\u05d4 \u05d9\u05d5\u05de\u05d9\u05ea +Save=\u05e9\u05de\u05d9\u05e8\u05d4 +Sign\ in\ first=\u05d9\u05e9 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05ea\u05d7\u05d9\u05dc\u05d4 +Password=\u05e1\u05d9\u05e1\u05de\u05d4 +Continue=\u05d4\u05de\u05e9\u05da +Confirm\ it\ is\ you=\u05d0\u05e9\u05e8\u05d5 \u05e9\u05d6\u05d4 \u05d0\u05ea\u05dd +Sign\ with\ this\ device=\u05d7\u05ea\u05d9\u05de\u05d4 \u05e2\u05dd \u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 +Payments\ leaving\ your\ accounts\ are\ signed\ by\ a\ key\ that\ never\ leaves\ this\ phone.\ Register\ this\ device\ to\ continue.=\u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05d4\u05d9\u05d5\u05e6\u05d0\u05d9\u05dd \u05de\u05d4\u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea \u05e9\u05dc\u05db\u05dd \u05e0\u05d7\u05ea\u05de\u05d9\u05dd \u05d1\u05de\u05e4\u05ea\u05d7 \u05e9\u05dc\u05e2\u05d5\u05dc\u05dd \u05d0\u05d9\u05e0\u05d5 \u05e2\u05d5\u05d6\u05d1 \u05d0\u05ea \u05d4\u05d8\u05dc\u05e4\u05d5\u05df \u05d4\u05d6\u05d4. \u05e8\u05e9\u05de\u05d5 \u05d0\u05ea \u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d4\u05de\u05e9\u05d9\u05da. +Register=\u05e8\u05d9\u05e9\u05d5\u05dd +Not\ now=\u05dc\u05d0 \u05e2\u05db\u05e9\u05d9\u05d5 +Exchange=\u05d1\u05d5\u05e8\u05e1\u05d4 +Markets=\u05e9\u05d5\u05d5\u05e7\u05d9\u05dd +No\ markets\ are\ open.=\u05d0\u05d9\u05df \u05e9\u05d5\u05d5\u05e7\u05d9\u05dd \u05e4\u05ea\u05d5\u05d7\u05d9\u05dd. +Index\ {0}=\u05de\u05d3\u05d3 {0} +Your\ trades=\u05d4\u05e2\u05e1\u05e7\u05d0\u05d5\u05ea \u05e9\u05dc\u05db\u05dd +You\ have\ not\ traded\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05d1\u05d9\u05e6\u05e2\u05ea\u05dd \u05e2\u05e1\u05e7\u05d0\u05d5\u05ea. +Welcome=\u05d1\u05e8\u05d5\u05db\u05d9\u05dd \u05d4\u05d1\u05d0\u05d9\u05dd +Hello,\ {0}=\u05e9\u05dc\u05d5\u05dd, {0} +Total\ balance=\u05d9\u05ea\u05e8\u05d4 \u05db\u05d5\u05dc\u05dc\u05ea +Not\ counted,\ no\ dollar\ price\:\ {0}=\u05dc\u05d0 \u05e0\u05e1\u05e4\u05e8, \u05d0\u05d9\u05df \u05de\u05d7\u05d9\u05e8 \u05d1\u05d3\u05d5\u05dc\u05e8\u05d9\u05dd: {0} +Pay\ someone=\u05ea\u05e9\u05dc\u05d5\u05dd \u05dc\u05de\u05d9\u05e9\u05d4\u05d5 +Trade=\u05de\u05e1\u05d7\u05e8 +Accounts=\u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea +You\ have\ no\ accounts\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05dc\u05db\u05dd \u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea. +Open\ an\ account=\u05e4\u05ea\u05d9\u05d7\u05ea \u05d7\u05e9\u05d1\u05d5\u05df +Checking=\u05e2\u05d5"\u05e9 +Savings=\u05d7\u05d9\u05e1\u05db\u05d5\u05df +Wallet=\u05d0\u05e8\u05e0\u05e7 +This\ device\ cannot\ sign\ payments\ yet=\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05d9\u05db\u05d5\u05dc \u05dc\u05d7\u05ea\u05d5\u05dd \u05e2\u05dc \u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd +Payments\ to\ other\ people\ and\ crypto\ withdrawals\ need\ a\ key\ that\ lives\ only\ on\ this\ phone.=\u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05dc\u05d0\u05e0\u05e9\u05d9\u05dd \u05d0\u05d7\u05e8\u05d9\u05dd \u05d5\u05de\u05e9\u05d9\u05db\u05d5\u05ea \u05e7\u05e8\u05d9\u05e4\u05d8\u05d5 \u05d3\u05d5\u05e8\u05e9\u05d9\u05dd \u05de\u05e4\u05ea\u05d7 \u05e9\u05e0\u05de\u05e6\u05d0 \u05e8\u05e7 \u05d1\u05d8\u05dc\u05e4\u05d5\u05df \u05d4\u05d6\u05d4. +Register\ this\ device=\u05e8\u05d9\u05e9\u05d5\u05dd \u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 +{0}\ wallet=\u05d0\u05e8\u05e0\u05e7 {0} +{0}\ checking=\u05e2\u05d5"\u05e9 {0} +{0}\ savings=\u05d7\u05d9\u05e1\u05db\u05d5\u05df {0} +Name\ (optional)=\u05e9\u05dd (\u05dc\u05d0 \u05d7\u05d5\u05d1\u05d4) +Open=\u05e4\u05ea\u05d9\u05d7\u05d4 +Not\ opened=\u05dc\u05d0 \u05e0\u05e4\u05ea\u05d7 +Locked=\u05e0\u05e2\u05d5\u05dc +Bastion\ is\ locked=Bastion \u05e0\u05e2\u05d5\u05dc +Unlock=\u05e9\u05d7\u05e8\u05d5\u05e8 \u05e0\u05e2\u05d9\u05dc\u05d4 +Sign\ in\ again=\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 +Not\ recognised.\ Try\ again.=\u05dc\u05d0 \u05d6\u05d5\u05d4\u05d4. \u05e0\u05e1\u05d5 \u05e9\u05d5\u05d1. +Last\ price=\u05de\u05d7\u05d9\u05e8 \u05d0\u05d7\u05e8\u05d5\u05df +Order\ book=\u05e1\u05e4\u05e8 \u05e4\u05e7\u05d5\u05d3\u05d5\u05ea +Place\ an\ order=\u05d4\u05d2\u05e9\u05ea \u05e4\u05e7\u05d5\u05d3\u05d4 +Your\ open\ orders=\u05d4\u05e4\u05e7\u05d5\u05d3\u05d5\u05ea \u05d4\u05e4\u05ea\u05d5\u05d7\u05d5\u05ea \u05e9\u05dc\u05db\u05dd +Bid\ {0}\ -\ Ask\ {1}=\u05e7\u05e0\u05d9\u05d9\u05d4 {0} - \u05de\u05db\u05d9\u05e8\u05d4 {1} +The\ book\ is\ empty.=\u05e1\u05e4\u05e8 \u05d4\u05e4\u05e7\u05d5\u05d3\u05d5\u05ea \u05e8\u05d9\u05e7. +Buy=\u05e7\u05e0\u05d9\u05d9\u05d4 +Sell=\u05de\u05db\u05d9\u05e8\u05d4 +at\ {0},\ {1}\ filled=\u05d1-{0}, {1} \u05d1\u05d5\u05e6\u05e2 +No\ open\ orders\ in\ this\ market.=\u05d0\u05d9\u05df \u05e4\u05e7\u05d5\u05d3\u05d5\u05ea \u05e4\u05ea\u05d5\u05d7\u05d5\u05ea \u05d1\u05e9\u05d5\u05e7 \u05d4\u05d6\u05d4. +Price\ in\ {0}=\u05de\u05d7\u05d9\u05e8 \u05d1-{0} +Amount\ of\ {0}=\u05db\u05de\u05d5\u05ea {0} +Spend,\ in\ {0}=\u05d4\u05d5\u05e6\u05d0\u05d4, \u05d1-{0} +In\ steps\ of\ {0}=\u05d1\u05e7\u05e4\u05d9\u05e6\u05d5\u05ea \u05e9\u05dc {0} +Place\ order=\u05d4\u05d2\u05e9\u05ea \u05e4\u05e7\u05d5\u05d3\u05d4 +Confirm\ order=\u05d0\u05d9\u05e9\u05d5\u05e8 \u05d4\u05e4\u05e7\u05d5\u05d3\u05d4 +Place=\u05d4\u05d2\u05e9\u05d4 +Order\ {0}=\u05e4\u05e7\u05d5\u05d3\u05d4 {0} +Enter\ how\ much\ to\ spend.=\u05d4\u05d6\u05d9\u05e0\u05d5 \u05db\u05de\u05d4 \u05dc\u05d4\u05d5\u05e6\u05d9\u05d0. +Enter\ an\ amount\ in\ steps\ of\ {0}.=\u05d4\u05d6\u05d9\u05e0\u05d5 \u05e1\u05db\u05d5\u05dd \u05d1\u05e7\u05e4\u05d9\u05e6\u05d5\u05ea \u05e9\u05dc {0}. +Enter\ a\ price.=\u05d4\u05d6\u05d9\u05e0\u05d5 \u05de\u05d7\u05d9\u05e8. +Buy\ {0}\ at\ the\ best\ prices,\ spending\ up\ to\ {1}?=\u05dc\u05e7\u05e0\u05d5\u05ea {0} \u05d1\u05de\u05d7\u05d9\u05e8\u05d9\u05dd \u05d4\u05d8\u05d5\u05d1\u05d9\u05dd \u05d1\u05d9\u05d5\u05ea\u05e8 \u05d5\u05dc\u05d4\u05d5\u05e6\u05d9\u05d0 \u05e2\u05d3 {1}? +Sell\ {0}\ at\ the\ best\ prices?=\u05dc\u05de\u05db\u05d5\u05e8 {0} \u05d1\u05de\u05d7\u05d9\u05e8\u05d9\u05dd \u05d4\u05d8\u05d5\u05d1\u05d9\u05dd \u05d1\u05d9\u05d5\u05ea\u05e8? +Buy\ {0}\ at\ {1}\ (about\ {2})?=\u05dc\u05e7\u05e0\u05d5\u05ea {0} \u05d1-{1} (\u05db-{2})? +Sell\ {0}\ at\ {1}\ (about\ {2})?=\u05dc\u05de\u05db\u05d5\u05e8 {0} \u05d1-{1} (\u05db-{2})? +filled=\u05d1\u05d5\u05e6\u05e2 +on\ the\ book=\u05d1\u05e1\u05e4\u05e8 \u05d4\u05e4\u05e7\u05d5\u05d3\u05d5\u05ea +ended,\ the\ rest\ unfilled=\u05d4\u05e1\u05ea\u05d9\u05d9\u05dd, \u05d4\u05d9\u05ea\u05e8\u05d4 \u05dc\u05d0 \u05d1\u05d5\u05e6\u05e2\u05d4 +Bought\ {0}=\u05e0\u05e7\u05e0\u05d4 {0} +Sold\ {0}=\u05e0\u05de\u05db\u05e8 {0} +at\ {0},\ fee\ {1}=\u05d1-{0}, \u05e2\u05de\u05dc\u05d4 {1} +Payees=\u05e0\u05de\u05e2\u05e0\u05d9\u05dd +Send\ crypto=\u05e9\u05dc\u05d9\u05d7\u05ea \u05e7\u05e8\u05d9\u05e4\u05d8\u05d5 +Security\ Center=\u05de\u05e8\u05db\u05d6 \u05d4\u05d0\u05d1\u05d8\u05d7\u05d4 +Settings=\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea +Sign\ out=\u05d4\u05ea\u05e0\u05ea\u05e7\u05d5\u05ea +Bank\ with\ Bastion=\u05d1\u05e0\u05e7\u05d0\u05d5\u05ea \u05e2\u05dd Bastion +Takes\ two\ minutes.\ You\ will\ set\ up\ a\ second\ factor\ and\ this\ phone's\ signing\ key\ as\ you\ go.=\u05dc\u05d5\u05e7\u05d7 \u05e9\u05ea\u05d9 \u05d3\u05e7\u05d5\u05ea. \u05ea\u05d5\u05da \u05db\u05d3\u05d9 \u05db\u05da \u05ea\u05d2\u05d3\u05d9\u05e8\u05d5 \u05d2\u05d5\u05e8\u05dd \u05e9\u05e0\u05d9 \u05d5\u05d0\u05ea \u05de\u05e4\u05ea\u05d7 \u05d4\u05d7\u05ea\u05d9\u05de\u05d4 \u05e9\u05dc \u05d4\u05d8\u05dc\u05e4\u05d5\u05df \u05d4\u05d6\u05d4. +Your\ name=\u05d4\u05e9\u05dd \u05e9\u05dc\u05db\u05dd +E-mail=\u05d0\u05d9\u05de\u05d9\u05d9\u05dc +Password\ again=\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d5\u05d1 +Create\ account=\u05d9\u05e6\u05d9\u05e8\u05ea \u05d7\u05e9\u05d1\u05d5\u05df +At\ least\ {0}\ characters=\u05dc\u05e4\u05d7\u05d5\u05ea {0} \u05ea\u05d5\u05d5\u05d9\u05dd +Both\ passwords\ match=\u05e9\u05ea\u05d9 \u05d4\u05e1\u05d9\u05e1\u05de\u05d0\u05d5\u05ea \u05d6\u05d4\u05d5\u05ea +Not\ your\ e-mail\ address=\u05dc\u05d0 \u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d0\u05d9\u05de\u05d9\u05d9\u05dc \u05e9\u05dc\u05db\u05dd +A\ few\ unrelated\ words\ make\ a\ password\ that\ is\ long\ and\ easy\ to\ remember.=\u05db\u05de\u05d4 \u05de\u05d9\u05dc\u05d9\u05dd \u05dc\u05d0 \u05e7\u05e9\u05d5\u05e8\u05d5\u05ea \u05d9\u05d5\u05e6\u05e8\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d4 \u05d0\u05e8\u05d5\u05db\u05d4 \u05d5\u05e7\u05dc\u05d4 \u05dc\u05d6\u05db\u05d9\u05e8\u05d4. +Sign\ in\ to\ continue.=\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5 \u05db\u05d3\u05d9 \u05dc\u05d4\u05de\u05e9\u05d9\u05da. +Authenticator\ app=\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d9\u05ea \u05d0\u05d9\u05de\u05d5\u05ea +Add\ a\ second\ factor=\u05d4\u05d5\u05e1\u05e4\u05ea \u05d2\u05d5\u05e8\u05dd \u05e9\u05e0\u05d9 +Scan\ this\ with\ an\ authenticator\ app.\ From\ now\ on\ a\ sign-in\ with\ your\ password\ also\ asks\ for\ the\ code\ it\ shows.=\u05e1\u05e8\u05e7\u05d5 \u05d6\u05d0\u05ea \u05d1\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d9\u05ea \u05d0\u05d9\u05de\u05d5\u05ea. \u05de\u05e2\u05db\u05e9\u05d9\u05d5, \u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e2\u05dd \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05ea\u05d3\u05e8\u05d5\u05e9 \u05d2\u05dd \u05d0\u05ea \u05d4\u05e7\u05d5\u05d3 \u05e9\u05d4\u05d9\u05d0 \u05de\u05e6\u05d9\u05d2\u05d4. +Or\ enter\ this\ key=\u05d0\u05d5 \u05d4\u05d6\u05d9\u05e0\u05d5 \u05d0\u05ea \u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05d6\u05d4 +Copy\ key=\u05d4\u05e2\u05ea\u05e7\u05ea \u05d4\u05de\u05e4\u05ea\u05d7 +Copy\ codes=\u05d4\u05e2\u05ea\u05e7\u05ea \u05d4\u05e7\u05d5\u05d3\u05d9\u05dd +Copied.\ The\ clipboard\ clears\ itself\ in\ a\ minute.=\u05d4\u05d5\u05e2\u05ea\u05e7. \u05d4\u05dc\u05d5\u05d7 \u05d9\u05ea\u05e8\u05d5\u05e7\u05df \u05de\u05e2\u05e6\u05de\u05d5 \u05d1\u05e2\u05d5\u05d3 \u05d3\u05e7\u05d4. +Open\ authenticator=\u05e4\u05ea\u05d9\u05d7\u05ea \u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d9\u05ea \u05d4\u05d0\u05d9\u05de\u05d5\u05ea +Six-digit\ code=\u05e7\u05d5\u05d3 \u05d1\u05df \u05e9\u05e9 \u05e1\u05e4\u05e8\u05d5\u05ea +Turn\ on=\u05d4\u05e4\u05e2\u05dc\u05d4 +New\ recovery\ codes=\u05e7\u05d5\u05d3\u05d9 \u05e9\u05d7\u05d6\u05d5\u05e8 \u05d7\u05d3\u05e9\u05d9\u05dd +Recovery\ codes=\u05e7\u05d5\u05d3\u05d9 \u05e9\u05d7\u05d6\u05d5\u05e8 +Save\ your\ recovery\ codes=\u05e9\u05de\u05e8\u05d5 \u05d0\u05ea \u05e7\u05d5\u05d3\u05d9 \u05d4\u05e9\u05d7\u05d6\u05d5\u05e8 +Each\ signs\ you\ in\ once\ if\ you\ lose\ your\ phone.\ Keep\ them\ somewhere\ safe\ and\ offline.\ They\ will\ not\ be\ shown\ again.=\u05db\u05dc \u05d0\u05d7\u05d3 \u05de\u05d4\u05dd \u05de\u05d7\u05d1\u05e8 \u05d0\u05ea\u05db\u05dd \u05e4\u05e2\u05dd \u05d0\u05d7\u05ea \u05d0\u05dd \u05ea\u05d0\u05d1\u05d3\u05d5 \u05d0\u05ea \u05d4\u05d8\u05dc\u05e4\u05d5\u05df. \u05e9\u05de\u05e8\u05d5 \u05d0\u05d5\u05ea\u05dd \u05d1\u05de\u05e7\u05d5\u05dd \u05d1\u05d8\u05d5\u05d7 \u05d5\u05de\u05d7\u05d5\u05e5 \u05dc\u05e8\u05e9\u05ea. \u05d4\u05dd \u05dc\u05d0 \u05d9\u05d5\u05e6\u05d2\u05d5 \u05e9\u05d5\u05d1. +I\ have\ saved\ them=\u05e9\u05de\u05e8\u05ea\u05d9 \u05d0\u05d5\u05ea\u05dd +Passkey=\u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4 +Sign\ in\ with\ a\ passkey=\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e2\u05dd \u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4 +A\ passkey\ is\ kept\ by\ your\ phone\ and\ unlocked\ by\ your\ face,\ fingerprint\ or\ screen\ lock.\ It\ cannot\ be\ phished,\ and\ replaces\ both\ the\ password\ and\ the\ code.=\u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4 \u05e0\u05e9\u05de\u05e8 \u05d1\u05d8\u05dc\u05e4\u05d5\u05df \u05e9\u05dc\u05db\u05dd \u05d5\u05e0\u05e4\u05ea\u05d7 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d4\u05e4\u05e0\u05d9\u05dd, \u05d8\u05d1\u05d9\u05e2\u05ea \u05d4\u05d0\u05e6\u05d1\u05e2 \u05d0\u05d5 \u05e0\u05e2\u05d9\u05dc\u05ea \u05d4\u05de\u05e1\u05da. \u05d0\u05d9 \u05d0\u05e4\u05e9\u05e8 \u05dc\u05d3\u05d5\u05d2 \u05d0\u05d5\u05ea\u05d5 \u05d1\u05e4\u05d9\u05e9\u05d9\u05e0\u05d2, \u05d5\u05d4\u05d5\u05d0 \u05de\u05d7\u05dc\u05d9\u05e3 \u05d2\u05dd \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05d5\u05d2\u05dd \u05d0\u05ea \u05d4\u05e7\u05d5\u05d3. +Create\ a\ passkey=\u05d9\u05e6\u05d9\u05e8\u05ea \u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4 +Bastion\ on\ {0}=Bastion \u05d1-{0} +Unlock\ with\ your\ face\ or\ fingerprint?=\u05dc\u05e9\u05d7\u05e8\u05e8 \u05e0\u05e2\u05d9\u05dc\u05d4 \u05e2\u05dd \u05d4\u05e4\u05e0\u05d9\u05dd \u05d0\u05d5 \u05d8\u05d1\u05d9\u05e2\u05ea \u05d4\u05d0\u05e6\u05d1\u05e2? +Bastion\ locks\ itself\ when\ you\ leave\ it.\ With\ this\ on,\ a\ biometric\ unlocks\ it;\ with\ it\ off,\ you\ sign\ in\ again.\ The\ device\ passcode\ is\ never\ accepted.=Bastion \u05e0\u05e0\u05e2\u05dc \u05de\u05e2\u05e6\u05de\u05d5 \u05db\u05e9\u05e2\u05d5\u05d6\u05d1\u05d9\u05dd \u05d0\u05d5\u05ea\u05d5. \u05db\u05e9\u05d6\u05d4 \u05e4\u05d5\u05e2\u05dc, \u05d0\u05de\u05e6\u05e2\u05d9 \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9 \u05de\u05e9\u05d7\u05e8\u05e8 \u05d0\u05d5\u05ea\u05d5; \u05db\u05e9\u05d6\u05d4 \u05db\u05d1\u05d5\u05d9, \u05de\u05ea\u05d7\u05d1\u05e8\u05d9\u05dd \u05de\u05d7\u05d3\u05e9. \u05e7\u05d5\u05d3 \u05d4\u05d2\u05d9\u05e9\u05d4 \u05e9\u05dc \u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05dc\u05e2\u05d5\u05dc\u05dd \u05d0\u05d9\u05e0\u05d5 \u05de\u05ea\u05e7\u05d1\u05dc. +Use\ biometrics=\u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4 +This\ device=\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 +Let\ this\ phone\ sign\ your\ payments=\u05dc\u05d0\u05e4\u05e9\u05e8 \u05dc\u05d8\u05dc\u05e4\u05d5\u05df \u05d4\u05d6\u05d4 \u05dc\u05d7\u05ea\u05d5\u05dd \u05e2\u05dc \u05d4\u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05e9\u05dc\u05db\u05dd +Bastion\ makes\ a\ key\ on\ this\ phone\ and\ gives\ the\ bank\ only\ its\ public\ half.\ Payments\ to\ other\ people\ and\ crypto\ withdrawals\ must\ carry\ its\ signature,\ so\ a\ stolen\ password\ alone\ cannot\ move\ your\ money.=Bastion \u05d9\u05d5\u05e6\u05e8 \u05de\u05e4\u05ea\u05d7 \u05d1\u05d8\u05dc\u05e4\u05d5\u05df \u05d4\u05d6\u05d4 \u05d5\u05de\u05e2\u05d1\u05d9\u05e8 \u05dc\u05d1\u05e0\u05e7 \u05e8\u05e7 \u05d0\u05ea \u05d4\u05d7\u05dc\u05e7 \u05d4\u05e6\u05d9\u05d1\u05d5\u05e8\u05d9 \u05e9\u05dc\u05d5. \u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05dc\u05d0\u05e0\u05e9\u05d9\u05dd \u05d0\u05d7\u05e8\u05d9\u05dd \u05d5\u05de\u05e9\u05d9\u05db\u05d5\u05ea \u05e7\u05e8\u05d9\u05e4\u05d8\u05d5 \u05d7\u05d9\u05d9\u05d1\u05d9\u05dd \u05dc\u05e9\u05d0\u05ea \u05d0\u05ea \u05d4\u05d7\u05ea\u05d9\u05de\u05d4 \u05e9\u05dc\u05d5, \u05db\u05da \u05e9\u05e1\u05d9\u05e1\u05de\u05d4 \u05d2\u05e0\u05d5\u05d1\u05d4 \u05dc\u05d1\u05d3\u05d4 \u05dc\u05d0 \u05d9\u05db\u05d5\u05dc\u05d4 \u05dc\u05d4\u05d6\u05d9\u05d6 \u05d0\u05ea \u05d4\u05db\u05e1\u05e3 \u05e9\u05dc\u05db\u05dd. +Later=\u05de\u05d0\u05d5\u05d7\u05e8 \u05d9\u05d5\u05ea\u05e8 +Add\ a\ payee=\u05d4\u05d5\u05e1\u05e4\u05ea \u05e0\u05de\u05e2\u05df +No\ payees\ yet.\ Add\ one\ by\ the\ e-mail\ address\ they\ bank\ with.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05e0\u05de\u05e2\u05e0\u05d9\u05dd. \u05d4\u05d5\u05e1\u05d9\u05e4\u05d5 \u05e0\u05de\u05e2\u05df \u05dc\u05e4\u05d9 \u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d0\u05d9\u05de\u05d9\u05d9\u05dc \u05e9\u05d1\u05d4 \u05d4\u05d5\u05d0 \u05de\u05e0\u05d4\u05dc \u05d7\u05e9\u05d1\u05d5\u05df. +New\ payee\:\ limited\ until\ {0}=\u05e0\u05de\u05e2\u05df \u05d7\u05d3\u05e9: \u05de\u05d5\u05d2\u05d1\u05dc \u05e2\u05d3 {0} +Cooling\ off=\u05ea\u05e7\u05d5\u05e4\u05ea \u05e6\u05d9\u05e0\u05d5\u05df +Ready=\u05de\u05d5\u05db\u05df +Their\ e-mail=\u05d4\u05d0\u05d9\u05de\u05d9\u05d9\u05dc \u05e9\u05dc\u05d4\u05dd +Name\ to\ show=\u05e9\u05dd \u05dc\u05d4\u05e6\u05d2\u05d4 +Payee\ added.\ It\ is\ limited\ while\ it\ cools\ off.=\u05d4\u05e0\u05de\u05e2\u05df \u05e0\u05d5\u05e1\u05e3. \u05d4\u05d5\u05d0 \u05de\u05d5\u05d2\u05d1\u05dc \u05d1\u05d6\u05de\u05df \u05ea\u05e7\u05d5\u05e4\u05ea \u05d4\u05e6\u05d9\u05e0\u05d5\u05df. +Not\ added=\u05dc\u05d0 \u05e0\u05d5\u05e1\u05e3 +Rename=\u05e9\u05d9\u05e0\u05d5\u05d9 \u05e9\u05dd +Remove=\u05d4\u05e1\u05e8\u05d4 +Remove\ payee=\u05d4\u05e1\u05e8\u05ea \u05e0\u05de\u05e2\u05df +Remove\ {0}?\ Adding\ them\ again\ starts\ a\ new\ cooling-off.=\u05dc\u05d4\u05e1\u05d9\u05e8 \u05d0\u05ea {0}? \u05d4\u05d5\u05e1\u05e4\u05d4 \u05de\u05d7\u05d3\u05e9 \u05ea\u05ea\u05d7\u05d9\u05dc \u05ea\u05e7\u05d5\u05e4\u05ea \u05e6\u05d9\u05e0\u05d5\u05df \u05d7\u05d3\u05e9\u05d4. +Done=\u05e1\u05d9\u05d5\u05dd +Sent=\u05e0\u05e9\u05dc\u05d7 +Reference=\u05d0\u05e1\u05de\u05db\u05ea\u05d4 +When=\u05de\u05ea\u05d9 +Note=\u05d4\u05e2\u05e8\u05d4 +What\ protects\ your\ account\ on\ this\ device.\ Nothing\ here\ is\ decoration\:\ each\ line\ is\ read\ from\ the\ app\ as\ it\ runs.=\u05de\u05d4 \u05de\u05d2\u05df \u05e2\u05dc \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05db\u05dd \u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4. \u05e9\u05d5\u05dd \u05d3\u05d1\u05e8 \u05db\u05d0\u05df \u05d0\u05d9\u05e0\u05d5 \u05e7\u05d9\u05e9\u05d5\u05d8: \u05db\u05dc \u05e9\u05d5\u05e8\u05d4 \u05e0\u05e7\u05e8\u05d0\u05ea \u05de\u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05d1\u05d6\u05de\u05df \u05e9\u05d4\u05d9\u05d0 \u05e4\u05d5\u05e2\u05dc\u05ea. +Back=\u05d7\u05d6\u05e8\u05d4 +Unlock\ with\ fingerprint\ or\ face=\u05e9\u05d7\u05e8\u05d5\u05e8 \u05e0\u05e2\u05d9\u05dc\u05d4 \u05e2\u05dd \u05d8\u05d1\u05d9\u05e2\u05ea \u05d0\u05e6\u05d1\u05e2 \u05d0\u05d5 \u05e4\u05e0\u05d9\u05dd +This\ device\ has\ no\ biometric\ enrolled.\ The\ lock\ asks\ for\ your\ password.=\u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05dc\u05d0 \u05d4\u05d5\u05d2\u05d3\u05e8 \u05d0\u05de\u05e6\u05e2\u05d9 \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9. \u05d4\u05e0\u05e2\u05d9\u05dc\u05d4 \u05ea\u05d1\u05e7\u05e9 \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05db\u05dd. +Off=\u05db\u05d1\u05d5\u05d9 +Sign-in=\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea +{0}\ recovery\ codes\ left=\u05e0\u05d5\u05ea\u05e8\u05d5 {0} \u05e7\u05d5\u05d3\u05d9 \u05e9\u05d7\u05d6\u05d5\u05e8 +Adds\ a\ six-digit\ code\ to\ every\ password\ sign-in.=\u05de\u05d5\u05e1\u05d9\u05e3 \u05e7\u05d5\u05d3 \u05d1\u05df \u05e9\u05e9 \u05e1\u05e4\u05e8\u05d5\u05ea \u05dc\u05db\u05dc \u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e2\u05dd \u05e1\u05d9\u05e1\u05de\u05d4. +On=\u05e4\u05d5\u05e2\u05dc +Payment\ signing\ key=\u05de\u05e4\u05ea\u05d7 \u05d7\u05ea\u05d9\u05de\u05ea \u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd +Payments\ from\ this\ phone\ are\ signed\ by\ a\ key\ that\ never\ leaves\ it.=\u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05de\u05d4\u05d8\u05dc\u05e4\u05d5\u05df \u05d4\u05d6\u05d4 \u05e0\u05d7\u05ea\u05de\u05d9\u05dd \u05d1\u05de\u05e4\u05ea\u05d7 \u05e9\u05dc\u05e2\u05d5\u05dc\u05dd \u05d0\u05d9\u05e0\u05d5 \u05e2\u05d5\u05d6\u05d1 \u05d0\u05d5\u05ea\u05d5. +Register\ this\ device\ to\ sign\ payments.=\u05e8\u05e9\u05de\u05d5 \u05d0\u05ea \u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d7\u05ea\u05d5\u05dd \u05e2\u05dc \u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd. +Sign\ in=\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea +Your\ bank,\ locked\ down.=\u05d4\u05d1\u05e0\u05e7 \u05e9\u05dc\u05db\u05dd, \u05e0\u05e2\u05d5\u05dc \u05d4\u05d9\u05d8\u05d1. +Verification=\u05d0\u05d9\u05de\u05d5\u05ea +Enter\ your\ code=\u05d4\u05d6\u05d9\u05e0\u05d5 \u05d0\u05ea \u05d4\u05e7\u05d5\u05d3 +Open\ your\ authenticator\ app\ and\ enter\ the\ six-digit\ code,\ or\ one\ of\ your\ recovery\ codes.=\u05e4\u05ea\u05d7\u05d5 \u05d0\u05ea \u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d9\u05ea \u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05d5\u05d4\u05d6\u05d9\u05e0\u05d5 \u05d0\u05ea \u05d4\u05e7\u05d5\u05d3 \u05d1\u05df \u05e9\u05e9 \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea, \u05d0\u05d5 \u05d0\u05d7\u05d3 \u05de\u05e7\u05d5\u05d3\u05d9 \u05d4\u05e9\u05d7\u05d6\u05d5\u05e8 \u05e9\u05dc\u05db\u05dd. +Code=\u05e7\u05d5\u05d3 +You\ have\ no\ account\ that\ can\ send\ money.=\u05d0\u05d9\u05df \u05dc\u05db\u05dd \u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05de\u05de\u05e0\u05d5 \u05d0\u05e4\u05e9\u05e8 \u05dc\u05e9\u05dc\u05d5\u05d7 \u05db\u05e1\u05e3. +Add\ a\ payee\ first,\ under\ Payees.=\u05d4\u05d5\u05e1\u05d9\u05e4\u05d5 \u05ea\u05d7\u05d9\u05dc\u05d4 \u05e0\u05de\u05e2\u05df, \u05ea\u05d7\u05ea \u05e0\u05de\u05e2\u05e0\u05d9\u05dd. +Note\ (optional)=\u05d4\u05e2\u05e8\u05d4 (\u05dc\u05d0 \u05d7\u05d5\u05d1\u05d4) +Review=\u05e1\u05e7\u05d9\u05e8\u05d4 +From=\u05de\u05d0\u05ea +To=\u05d0\u05dc +Choose\ who\ to\ pay.=\u05d1\u05d7\u05e8\u05d5 \u05dc\u05de\u05d9 \u05dc\u05e9\u05dc\u05dd. +That\ is\ more\ than\ this\ account\ holds.=\u05d6\u05d4 \u05d9\u05d5\u05ea\u05e8 \u05de\u05de\u05d4 \u05e9\u05d9\u05e9 \u05d1\u05d7\u05e9\u05d1\u05d5\u05df \u05d4\u05d6\u05d4. +Choose\ two\ different\ accounts.=\u05d1\u05d7\u05e8\u05d5 \u05e9\u05e0\u05d9 \u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea \u05e9\u05d5\u05e0\u05d9\u05dd. +Confirm=\u05d0\u05d9\u05e9\u05d5\u05e8 +Send\ {0}\ from\ {1}\ to\ {2}?=\u05dc\u05e9\u05dc\u05d5\u05d7 {0} \u05de-{1} \u05d0\u05dc {2}? +Loading...=\u05d8\u05d5\u05e2\u05df... +Open\ a\ crypto\ wallet\ to\ send\ coins\ from.=\u05e4\u05ea\u05d7\u05d5 \u05d0\u05e8\u05e0\u05e7 \u05e7\u05e8\u05d9\u05e4\u05d8\u05d5 \u05e9\u05de\u05de\u05e0\u05d5 \u05d0\u05e4\u05e9\u05e8 \u05dc\u05e9\u05dc\u05d5\u05d7 \u05de\u05d8\u05d1\u05e2\u05d5\u05ea. +Your\ addresses=\u05d4\u05db\u05ea\u05d5\u05d1\u05d5\u05ea \u05e9\u05dc\u05db\u05dd +Add\ an\ address=\u05d4\u05d5\u05e1\u05e4\u05ea \u05db\u05ea\u05d5\u05d1\u05ea +No\ addresses\ yet.\ Coins\ can\ be\ sent\ only\ to\ an\ address\ on\ this\ list.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05d5\u05ea. \u05d0\u05e4\u05e9\u05e8 \u05dc\u05e9\u05dc\u05d5\u05d7 \u05de\u05d8\u05d1\u05e2\u05d5\u05ea \u05e8\u05e7 \u05dc\u05db\u05ea\u05d5\u05d1\u05ea \u05e9\u05d1\u05e8\u05e9\u05d9\u05de\u05d4 \u05d4\u05d6\u05d5. +Usable\ from\ {0}=\u05e0\u05d9\u05ea\u05df \u05dc\u05e9\u05d9\u05de\u05d5\u05e9 \u05de-{0} +Waiting=\u05de\u05de\u05ea\u05d9\u05df +No\ {0}\ address\ is\ ready\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05ea {0} \u05de\u05d5\u05db\u05e0\u05d4. +Choose\ an\ address.=\u05d1\u05d7\u05e8\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea. +Enter\ an\ amount.=\u05d4\u05d6\u05d9\u05e0\u05d5 \u05e1\u05db\u05d5\u05dd. +That\ is\ more\ than\ this\ wallet\ holds.=\u05d6\u05d4 \u05d9\u05d5\u05ea\u05e8 \u05de\u05de\u05d4 \u05e9\u05d9\u05e9 \u05d1\u05d0\u05e8\u05e0\u05e7 \u05d4\u05d6\u05d4. +Send\ {0}\ to\ {1}?\ This\ cannot\ be\ undone.=\u05dc\u05e9\u05dc\u05d5\u05d7 {0} \u05d0\u05dc {1}? \u05d0\u05d9 \u05d0\u05e4\u05e9\u05e8 \u05dc\u05d1\u05d8\u05dc \u05d6\u05d0\u05ea. +Address=\u05db\u05ea\u05d5\u05d1\u05ea +Label,\ like\ My\ hardware\ wallet=\u05ea\u05d5\u05d5\u05d9\u05ea, \u05dc\u05de\u05e9\u05dc \u05d0\u05e8\u05e0\u05e7 \u05d4\u05d7\u05d5\u05de\u05e8\u05d4 \u05e9\u05dc\u05d9 +Address\ added=\u05d4\u05db\u05ea\u05d5\u05d1\u05ea \u05e0\u05d5\u05e1\u05e4\u05d4 +For\ your\ safety\ it\ can\ be\ used\ from\ {0}.=\u05dc\u05de\u05e2\u05df \u05d1\u05d9\u05d8\u05d7\u05d5\u05e0\u05db\u05dd \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05e9\u05ea\u05de\u05e9 \u05d1\u05d4 \u05d4\u05d7\u05dc \u05de-{0}. +Remove\ address=\u05d4\u05e1\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea +Remove\ {0}?\ Adding\ it\ again\ starts\ a\ new\ wait.=\u05dc\u05d4\u05e1\u05d9\u05e8 \u05d0\u05ea {0}? \u05d4\u05d5\u05e1\u05e4\u05d4 \u05de\u05d7\u05d3\u05e9 \u05ea\u05ea\u05d7\u05d9\u05dc \u05d4\u05de\u05ea\u05e0\u05d4 \u05d7\u05d3\u05e9\u05d4. +Not\ removed=\u05dc\u05d0 \u05d4\u05d5\u05e1\u05e8 +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05d2\u05d9\u05e2 \u05dc\u05e9\u05e8\u05ea. \u05d1\u05d3\u05e7\u05d5 \u05d0\u05ea \u05d4\u05d7\u05d9\u05d1\u05d5\u05e8 \u05e9\u05dc\u05db\u05dd. +Something\ went\ wrong\ ({0}).\ Try\ again.=\u05de\u05e9\u05d4\u05d5 \u05d4\u05e9\u05ea\u05d1\u05e9 ({0}). \u05e0\u05e1\u05d5 \u05e9\u05d5\u05d1. +The\ server\ changed.\ Sign\ in\ again.=\u05d4\u05e9\u05e8\u05ea \u05d4\u05e9\u05ea\u05e0\u05d4. \u05d4\u05ea\u05d7\u05d1\u05e8\u05d5 \u05de\u05d7\u05d3\u05e9. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=\u05d9\u05d5\u05ea\u05e8 \u05de\u05d3\u05d9 \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea. \u05d4\u05de\u05ea\u05d9\u05e0\u05d5 \u05d3\u05e7\u05d4 \u05d5\u05e0\u05e1\u05d5 \u05e9\u05d5\u05d1. +This\ account\ is\ locked\ for\ a\ while\ after\ too\ many\ attempts.=\u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05d4\u05d6\u05d4 \u05e0\u05e0\u05e2\u05dc \u05dc\u05d6\u05de\u05df \u05de\u05d4 \u05dc\u05d0\u05d7\u05e8 \u05d9\u05d5\u05ea\u05e8 \u05de\u05d3\u05d9 \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=\u05d4\u05d0\u05d9\u05de\u05d9\u05d9\u05dc \u05d5\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05d4\u05d0\u05dc\u05d4 \u05d0\u05d9\u05e0\u05dd \u05ea\u05d5\u05d0\u05de\u05d9\u05dd \u05dc\u05d0\u05e3 \u05d7\u05e9\u05d1\u05d5\u05df. +That\ code\ is\ not\ right.\ Codes\ change\ every\ 30\ seconds.=\u05d4\u05e7\u05d5\u05d3 \u05d4\u05d6\u05d4 \u05e9\u05d2\u05d5\u05d9. \u05d4\u05e7\u05d5\u05d3\u05d9\u05dd \u05de\u05ea\u05d7\u05dc\u05e4\u05d9\u05dd \u05db\u05dc 30 \u05e9\u05e0\u05d9\u05d5\u05ea. +Passkey\ sign-in\ is\ not\ available\ right\ now.=\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e2\u05dd \u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4 \u05d0\u05d9\u05e0\u05d4 \u05d6\u05de\u05d9\u05e0\u05d4 \u05db\u05e8\u05d2\u05e2. +That\ passkey\ was\ not\ accepted.=\u05de\u05e4\u05ea\u05d7 \u05d4\u05d2\u05d9\u05e9\u05d4 \u05d4\u05d6\u05d4 \u05dc\u05d0 \u05d4\u05ea\u05e7\u05d1\u05dc. +Passkey\ sign-in\ was\ cancelled.=\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e2\u05dd \u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4 \u05d1\u05d5\u05d8\u05dc\u05d4. +Signing\ in\ failed.\ Try\ again.=\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4. \u05e0\u05e1\u05d5 \u05e9\u05d5\u05d1. +Sign\ in\ again\ to\ add\ a\ passkey.=\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5 \u05de\u05d7\u05d3\u05e9 \u05db\u05d3\u05d9 \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4. +Passkeys\ are\ not\ available\ right\ now.=\u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05d2\u05d9\u05e9\u05d4 \u05d0\u05d9\u05e0\u05dd \u05d6\u05de\u05d9\u05e0\u05d9\u05dd \u05db\u05e8\u05d2\u05e2. +That\ passkey\ was\ not\ saved.=\u05de\u05e4\u05ea\u05d7 \u05d4\u05d2\u05d9\u05e9\u05d4 \u05d4\u05d6\u05d4 \u05dc\u05d0 \u05e0\u05e9\u05de\u05e8. +Creating\ the\ passkey\ was\ cancelled.=\u05d9\u05e6\u05d9\u05e8\u05ea \u05de\u05e4\u05ea\u05d7 \u05d4\u05d2\u05d9\u05e9\u05d4 \u05d1\u05d5\u05d8\u05dc\u05d4. +This\ device\ has\ no\ secure\ storage\ for\ the\ key.=\u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05d0\u05d9\u05df \u05d0\u05d7\u05e1\u05d5\u05df \u05de\u05d0\u05d5\u05d1\u05d8\u05d7 \u05dc\u05de\u05e4\u05ea\u05d7. +Unlock\ Bastion=\u05e9\u05d7\u05e8\u05d5\u05e8 \u05e0\u05e2\u05d9\u05dc\u05ea Bastion +Unlock\ Bastion\ and\ the\ keys\ that\ sign\ your\ payments=\u05e9\u05d7\u05e8\u05d5\u05e8 \u05e0\u05e2\u05d9\u05dc\u05ea Bastion \u05d5\u05d4\u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05d4\u05d7\u05d5\u05ea\u05de\u05d9\u05dd \u05e2\u05dc \u05d4\u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05e9\u05dc\u05db\u05dd +Network=\u05e8\u05e9\u05ea +Keys\ and\ sign-in=\u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05d5\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea +Build\ hardening=\u05d4\u05e7\u05e9\u05d7\u05ea \u05d4\u05d1\u05e0\u05d9\u05d9\u05d4 +No\ cleartext=\u05dc\u05dc\u05d0 \u05d8\u05e7\u05e1\u05d8 \u05d2\u05dc\u05d5\u05d9 +Development\ server\ =\u05e9\u05e8\u05ea \u05e4\u05d9\u05ea\u05d5\u05d7 +\ is\ exempt\:\ plain\ HTTP\ to\ this\ machine\ only=\ \u05de\u05d5\u05d7\u05e8\u05d2: HTTP \u05e8\u05d2\u05d9\u05dc \u05dc\u05de\u05d7\u05e9\u05d1 \u05d4\u05d6\u05d4 \u05d1\u05dc\u05d1\u05d3 +Every\ request\ is\ https;\ http\ is\ refused=\u05db\u05dc \u05d1\u05e7\u05e9\u05d4 \u05d4\u05d9\u05d0 https; http \u05e0\u05d3\u05d7\u05d4 +Host\ allow-list=\u05e8\u05e9\u05d9\u05de\u05ea \u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05de\u05d5\u05ea\u05e8\u05d9\u05dd +Exempt\ while\ the\ development\ server\ is\ in\ use=\u05de\u05d5\u05d7\u05e8\u05d2 \u05db\u05dc \u05e2\u05d5\u05d3 \u05e9\u05e8\u05ea \u05d4\u05e4\u05d9\u05ea\u05d5\u05d7 \u05d1\u05e9\u05d9\u05de\u05d5\u05e9 +Only\ =\u05e8\u05e7 +Over-the-air\ pins=\u05e0\u05e2\u05d9\u05e6\u05d5\u05ea \u05e2\u05d3\u05db\u05d5\u05df \u05de\u05e8\u05d7\u05d5\u05e7 +Published\ by\ the\ attestation\ service;\ none\ in\ this\ build=\u05de\u05e4\u05d5\u05e8\u05e1\u05dd \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e9\u05d9\u05e8\u05d5\u05ea \u05d4\u05d0\u05d9\u05e9\u05d5\u05e8; \u05d0\u05d9\u05df \u05d1\u05d1\u05e0\u05d9\u05d9\u05d4 \u05d4\u05d6\u05d5 +\ hosts,\ version\ =\ \u05de\u05d0\u05e8\u05d7\u05d9\u05dd, \u05d2\u05e8\u05e1\u05d4 +\ (refresh\ due)=\ (\u05e0\u05d3\u05e8\u05e9 \u05e8\u05e2\u05e0\u05d5\u05df) +App\ attestation\ token=\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d0\u05d9\u05e9\u05d5\u05e8 \u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 +AppShield\ engine\ =\u05de\u05e0\u05d5\u05e2 AppShield +AppShield\ needs\ the\ enterprise\ engine\ in\ a\ cloud\ build;\ inert\ here=AppShield \u05d3\u05d5\u05e8\u05e9 \u05d0\u05ea \u05de\u05e0\u05d5\u05e2 \u05d4-Enterprise \u05d1\u05d1\u05e0\u05d9\u05d9\u05d4 \u05d1\u05e2\u05e0\u05df; \u05db\u05d0\u05df \u05d0\u05d9\u05e0\u05d5 \u05e4\u05e2\u05d9\u05dc +Device\ signing\ key=\u05de\u05e4\u05ea\u05d7 \u05d7\u05ea\u05d9\u05de\u05ea \u05d4\u05de\u05db\u05e9\u05d9\u05e8 +P-256,\ registered\ as\ =P-256, \u05e8\u05e9\u05d5\u05dd \u05d1\u05e9\u05dd +;\ signs\ every\ payment=; \u05d7\u05d5\u05ea\u05dd \u05e2\u05dc \u05db\u05dc \u05ea\u05e9\u05dc\u05d5\u05dd +Not\ registered\:\ payments\ that\ need\ a\ signature\ are\ refused=\u05dc\u05d0 \u05e8\u05e9\u05d5\u05dd: \u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05d4\u05d3\u05d5\u05e8\u05e9\u05d9\u05dd \u05d7\u05ea\u05d9\u05de\u05d4 \u05e0\u05d3\u05d7\u05d9\u05dd +Idempotency\ keys=Idempotency-Keys +Each\ money\ action\ carries\ one,\ so\ a\ retry\ never\ pays\ twice=\u05db\u05dc \u05e4\u05e2\u05d5\u05dc\u05ea \u05db\u05e1\u05e3 \u05e0\u05d5\u05e9\u05d0\u05ea \u05de\u05e4\u05ea\u05d7 \u05db\u05d6\u05d4, \u05db\u05da \u05e9\u05e0\u05d9\u05e1\u05d9\u05d5\u05df \u05d7\u05d5\u05d6\u05e8 \u05dc\u05e2\u05d5\u05dc\u05dd \u05dc\u05d0 \u05de\u05e9\u05dc\u05dd \u05e4\u05e2\u05de\u05d9\u05d9\u05dd +Second\ factor\ and\ passkeys=\u05d2\u05d5\u05e8\u05dd \u05e9\u05e0\u05d9 \u05d5\u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05d2\u05d9\u05e9\u05d4 +Authenticator\ codes\ and\ passkeys=\u05e7\u05d5\u05d3\u05d9 \u05d0\u05d9\u05de\u05d5\u05ea \u05d5\u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05d2\u05d9\u05e9\u05d4 +Authenticator\ codes\ (no\ passkeys\ on\ this\ platform)=\u05e7\u05d5\u05d3\u05d9 \u05d0\u05d9\u05de\u05d5\u05ea (\u05d0\u05d9\u05df \u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05d2\u05d9\u05e9\u05d4 \u05d1\u05e4\u05dc\u05d8\u05e4\u05d5\u05e8\u05de\u05d4 \u05d4\u05d6\u05d5) +Biometric\ lock=\u05e0\u05e2\u05d9\u05dc\u05d4 \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05ea +On\ start,\ on\ return\ and\ when\ idle=\u05d1\u05d4\u05e4\u05e2\u05dc\u05d4, \u05d1\u05d7\u05d6\u05e8\u05d4 \u05d5\u05d1\u05d7\u05d5\u05e1\u05e8 \u05e4\u05e2\u05d9\u05dc\u05d5\u05ea +No\ biometrics\ enrolled\:\ the\ lock\ asks\ for\ the\ password\ instead=\u05dc\u05d0 \u05d4\u05d5\u05d2\u05d3\u05e8\u05d4 \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4: \u05d4\u05e0\u05e2\u05d9\u05dc\u05d4 \u05ea\u05d1\u05e7\u05e9 \u05d1\u05de\u05e7\u05d5\u05dd \u05d6\u05d0\u05ea \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 +Auto-lock=\u05e0\u05e2\u05d9\u05dc\u05d4 \u05d0\u05d5\u05d8\u05d5\u05de\u05d8\u05d9\u05ea +After\ =\u05d0\u05d7\u05e8\u05d9 +s\ idle\ or\ =\u05e9\u05f3 \u05d7\u05d5\u05e1\u05e8 \u05e4\u05e2\u05d9\u05dc\u05d5\u05ea \u05d0\u05d5 +s\ in\ the\ background=\u05e9\u05f3 \u05d1\u05e8\u05e7\u05e2 +Platform\ attestation=\u05d0\u05d9\u05e9\u05d5\u05e8 \u05e4\u05dc\u05d8\u05e4\u05d5\u05e8\u05de\u05d4 +Play\ Integrity=Play Integrity +App\ Attest=App Attest +Play\ Integrity\ /\ App\ Attest\ need\ a\ device\ build=Play Integrity / App Attest \u05d3\u05d5\u05e8\u05e9\u05d9\u05dd \u05d1\u05e0\u05d9\u05d9\u05ea \u05de\u05db\u05e9\u05d9\u05e8 +Accessibility\ services=\u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 \u05e0\u05d2\u05d9\u05e9\u05d5\u05ea +Can\ read\ the\ screen\:\ =\u05d9\u05db\u05d5\u05dc\u05d9\u05dd \u05dc\u05e7\u05e8\u05d5\u05d0 \u05d0\u05ea \u05d4\u05de\u05e1\u05da: +None\ that\ can\ read\ the\ screen=\u05d0\u05d9\u05df \u05db\u05d0\u05dc\u05d4 \u05e9\u05d9\u05db\u05d5\u05dc\u05d9\u05dd \u05dc\u05e7\u05e8\u05d5\u05d0 \u05d0\u05ea \u05d4\u05de\u05e1\u05da +Tapjacking=Tapjacking +Touches\ through\ overlays\ are\ dropped=\u05e0\u05d2\u05d9\u05e2\u05d5\u05ea \u05d3\u05e8\u05da \u05e9\u05db\u05d1\u05d5\u05ea-\u05e2\u05dc \u05e0\u05d6\u05e8\u05e7\u05d5\u05ea +Policy\ set;\ only\ Android\ has\ overlays=\u05d4\u05de\u05d3\u05d9\u05e0\u05d9\u05d5\u05ea \u05d4\u05d5\u05d2\u05d3\u05e8\u05d4; \u05e8\u05e7 \u05d1\u05d0\u05e0\u05d3\u05e8\u05d5\u05d0\u05d9\u05d3 \u05d9\u05e9 \u05e9\u05db\u05d1\u05d5\u05ea-\u05e2\u05dc +Screenshot\ blocking=\u05d7\u05e1\u05d9\u05de\u05ea \u05e6\u05d9\u05dc\u05d5\u05de\u05d9 \u05de\u05e1\u05da +Set\ on\ sensitive\ screens;\ the\ simulator\ cannot\ block\ captures=\u05de\u05d5\u05d2\u05d3\u05e8 \u05d1\u05de\u05e1\u05db\u05d9\u05dd \u05e8\u05d2\u05d9\u05e9\u05d9\u05dd; \u05d4\u05e1\u05d9\u05de\u05d5\u05dc\u05d8\u05d5\u05e8 \u05d0\u05d9\u05e0\u05d5 \u05d9\u05db\u05d5\u05dc \u05dc\u05d7\u05e1\u05d5\u05dd \u05e6\u05d9\u05dc\u05d5\u05de\u05d9\u05dd +Sensitive\ screens,\ and\ the\ app\ switcher\ card=\u05de\u05e1\u05db\u05d9\u05dd \u05e8\u05d2\u05d9\u05e9\u05d9\u05dd \u05d5\u05db\u05e8\u05d8\u05d9\u05e1 \u05de\u05d7\u05dc\u05d9\u05e3 \u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d5\u05ea +Sensitive\ screens\ and\ the\ recent-apps\ card=\u05de\u05e1\u05db\u05d9\u05dd \u05e8\u05d2\u05d9\u05e9\u05d9\u05dd \u05d5\u05db\u05e8\u05d8\u05d9\u05e1 \u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d5\u05ea \u05d4\u05d0\u05d7\u05e8\u05d5\u05e0\u05d5\u05ea +Clipboard\ clearing=\u05e0\u05d9\u05e7\u05d5\u05d9 \u05d4\u05dc\u05d5\u05d7 +Copied\ numbers\ are\ wiped\ after\ =\u05de\u05e1\u05e4\u05e8\u05d9\u05dd \u05e9\u05d4\u05d5\u05e2\u05ea\u05e7\u05d5 \u05e0\u05de\u05d7\u05e7\u05d9\u05dd \u05d0\u05d7\u05e8\u05d9 +Code\ hardening=\u05d4\u05e7\u05e9\u05d7\u05ea \u05e7\u05d5\u05d3 +Level\ =\u05e8\u05de\u05d4 +,\ mapping\ =, \u05de\u05d9\u05e4\u05d5\u05d9 +Declared\ =\u05d4\u05d5\u05e6\u05d4\u05e8 +;\ applied\ by\ the\ build\ server=; \u05de\u05d9\u05d5\u05e9\u05dd \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e9\u05e8\u05ea \u05d4\u05d1\u05e0\u05d9\u05d9\u05d4 +Jailbreak\ exit\ (iOS)=\u05d9\u05e6\u05d9\u05d0\u05d4 \u05d1\u05d6\u05d9\u05d4\u05d5\u05d9 Jailbreak (iOS) +App\ switcher\ blanking\ (iOS)=\u05d4\u05e1\u05ea\u05e8\u05d4 \u05d1\u05de\u05d7\u05dc\u05d9\u05e3 \u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d5\u05ea (iOS) +Play\ Integrity\ (Android)=Play Integrity (Android) +Certificate\ pinning=\u05e0\u05e2\u05d9\u05e6\u05ea \u05ea\u05e2\u05d5\u05d3\u05d5\u05ea +Not\ applied\ to\ the\ development\ server\ (it\ has\ no\ certificate)=\u05dc\u05d0 \u05de\u05d9\u05d5\u05e9\u05dd \u05e2\u05dc \u05e9\u05e8\u05ea \u05d4\u05e4\u05d9\u05ea\u05d5\u05d7 (\u05d0\u05d9\u05df \u05dc\u05d5 \u05ea\u05e2\u05d5\u05d3\u05d4) +Placeholder\ pins\:\ put\ the\ server's\ SPKI\ digests\ in\ AppConfig.PINS=\u05e0\u05e2\u05d9\u05e6\u05d5\u05ea \u05d6\u05de\u05e0\u05d9\u05d5\u05ea: \u05d4\u05db\u05e0\u05d9\u05e1\u05d5 \u05d0\u05ea \u05ea\u05e7\u05e6\u05d9\u05e8\u05d9 \u05d4-SPKI \u05e9\u05dc \u05d4\u05e9\u05e8\u05ea \u05dc-AppConfig.PINS +\ hosts\ pinned=\ \u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05e0\u05e2\u05d5\u05e6\u05d9\u05dd +\ mismatches\ refused=\ \u05d0\u05d9-\u05d4\u05ea\u05d0\u05de\u05d5\u05ea \u05e0\u05d3\u05d7\u05d5 +Token\ and\ key\ storage=\u05d0\u05d7\u05e1\u05d5\u05df \u05d0\u05e1\u05d9\u05de\u05d5\u05e0\u05d9\u05dd \u05d5\u05de\u05e4\u05ea\u05d7\u05d5\u05ea +Hardware-backed\ keystore=Keystore \u05de\u05d2\u05d5\u05d1\u05d4 \u05d7\u05d5\u05de\u05e8\u05d4 +OS\ keystore=Keystore \u05e9\u05dc \u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05d4\u05e4\u05e2\u05dc\u05d4 +The\ simulator\ keeps\ them\ in\ a\ plain\ file=\u05d4\u05e1\u05d9\u05de\u05d5\u05dc\u05d8\u05d5\u05e8 \u05e9\u05d5\u05de\u05e8 \u05d0\u05d5\u05ea\u05dd \u05d1\u05e7\u05d5\u05d1\u05e5 \u05e8\u05d2\u05d9\u05dc +No\ keystore\ on\ this\ device\:\ tokens\ are\ not\ kept\ between\ runs=\u05d0\u05d9\u05df Keystore \u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4: \u05d0\u05e1\u05d9\u05de\u05d5\u05e0\u05d9\u05dd \u05d0\u05d9\u05e0\u05dd \u05e0\u05e9\u05de\u05e8\u05d9\u05dd \u05d1\u05d9\u05df \u05d4\u05e4\u05e2\u05dc\u05d5\u05ea +Root\ and\ jailbreak\ checks=\u05d1\u05d3\u05d9\u05e7\u05d5\u05ea Root \u05d5-Jailbreak +Run\ on\ a\ phone;\ the\ simulator\ reports\ nothing=\u05d4\u05e8\u05d9\u05e6\u05d5 \u05d1\u05d8\u05dc\u05e4\u05d5\u05df; \u05d4\u05e1\u05d9\u05de\u05d5\u05dc\u05d8\u05d5\u05e8 \u05d0\u05d9\u05e0\u05d5 \u05de\u05d3\u05d5\u05d5\u05d7 \u05e2\u05dc \u05d3\u05d1\u05e8 +This\ device\ looks\ modified\:\ =\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05e0\u05e8\u05d0\u05d4 \u05e9\u05e9\u05d5\u05e0\u05d4: +No\ signs\ of\ root\ or\ jailbreak=\u05d0\u05d9\u05df \u05e1\u05d9\u05de\u05e0\u05d9\u05dd \u05dc-Root \u05d0\u05d5 Jailbreak +OK=\u05d0\u05d9\u05e9\u05d5\u05e8 +Last\ used\ {0}=\u05e9\u05d9\u05de\u05d5\u05e9 \u05d0\u05d7\u05e8\u05d5\u05df {0} +Add\ a\ passkey=\u05d4\u05d5\u05e1\u05e4\u05ea \u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4 +This\ device\ cannot\ create\ passkeys.=\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05d0\u05d9\u05e0\u05d5 \u05d9\u05db\u05d5\u05dc \u05dc\u05d9\u05e6\u05d5\u05e8 \u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05d2\u05d9\u05e9\u05d4. +Remove\ passkey=\u05d4\u05e1\u05e8\u05ea \u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4 +Remove\ {0}?\ It\ will\ no\ longer\ sign\ in\ to\ this\ account.=\u05dc\u05d4\u05e1\u05d9\u05e8 \u05d0\u05ea {0}? \u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d9\u05d4\u05d9\u05d4 \u05e2\u05d5\u05d3 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05d0\u05d9\u05ea\u05d5 \u05dc\u05d7\u05e9\u05d1\u05d5\u05df \u05d4\u05d6\u05d4. +Code\ from\ your\ app=\u05e7\u05d5\u05d3 \u05de\u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05e9\u05dc\u05db\u05dd +Authenticator\ code=\u05e7\u05d5\u05d3 \u05d0\u05d9\u05de\u05d5\u05ea +Please\ sign\ in\ again.=\u05d0\u05e0\u05d0 \u05d4\u05ea\u05d7\u05d1\u05e8\u05d5 \u05de\u05d7\u05d3\u05e9. +Your\ account\ is\ not\ allowed\ to\ do\ that.=\u05dc\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05db\u05dd \u05d0\u05d9\u05df \u05d4\u05e8\u05e9\u05d0\u05d4 \u05dc\u05e2\u05e9\u05d5\u05ea \u05d6\u05d0\u05ea. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=\u05d9\u05d5\u05ea\u05e8 \u05de\u05d3\u05d9 \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea. \u05d4\u05de\u05ea\u05d9\u05e0\u05d5 \u05e8\u05d2\u05e2 \u05d5\u05e0\u05e1\u05d5 \u05e9\u05d5\u05d1. +Unlock\ Bastion\ first.=\u05e9\u05d7\u05e8\u05e8\u05d5 \u05ea\u05d7\u05d9\u05dc\u05d4 \u05d0\u05ea \u05e0\u05e2\u05d9\u05dc\u05ea Bastion. +PIN=PIN +Unlock\ with\ PIN=\u05e9\u05d7\u05e8\u05d5\u05e8 \u05e0\u05e2\u05d9\u05dc\u05d4 \u05e2\u05dd PIN +Not\ recognised.\ Try\ again,\ or\ use\ your\ PIN.=\u05dc\u05d0 \u05d6\u05d5\u05d4\u05d4. \u05e0\u05e1\u05d5 \u05e9\u05d5\u05d1, \u05d0\u05d5 \u05d4\u05e9\u05ea\u05de\u05e9\u05d5 \u05d1-PIN. +Enter\ your\ PIN\ of\ at\ least\ {0}\ digits.=\u05d4\u05d6\u05d9\u05e0\u05d5 PIN \u05e9\u05dc {0} \u05e1\u05e4\u05e8\u05d5\u05ea \u05dc\u05e4\u05d7\u05d5\u05ea. +Wrong\ PIN.\ One\ try\ left\ before\ the\ keys\ on\ this\ device\ are\ erased.=PIN \u05e9\u05d2\u05d5\u05d9. \u05e0\u05d5\u05ea\u05e8 \u05e0\u05d9\u05e1\u05d9\u05d5\u05df \u05d0\u05d7\u05d3 \u05dc\u05e4\u05e0\u05d9 \u05e9\u05d4\u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05d9\u05d9\u05de\u05d7\u05e7\u05d5. +Wrong\ PIN.\ {0}\ tries\ left.=PIN \u05e9\u05d2\u05d5\u05d9. \u05e0\u05d5\u05ea\u05e8\u05d5 {0} \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea. +Too\ many\ wrong\ PINs.\ The\ keys\ on\ this\ device\ were\ erased.=\u05d9\u05d5\u05ea\u05e8 \u05de\u05d3\u05d9 PIN \u05e9\u05d2\u05d5\u05d9\u05d9\u05dd. \u05d4\u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05e0\u05de\u05d7\u05e7\u05d5. +Unlock\ PIN=PIN \u05dc\u05e9\u05d7\u05e8\u05d5\u05e8 \u05e0\u05e2\u05d9\u05dc\u05d4 +Choose\ an\ unlock\ PIN=\u05d1\u05d7\u05e8\u05d5 PIN \u05dc\u05e9\u05d7\u05e8\u05d5\u05e8 \u05e0\u05e2\u05d9\u05dc\u05d4 +Set\ PIN=\u05d4\u05d2\u05d3\u05e8\u05ea PIN +Change\ PIN=\u05e9\u05d9\u05e0\u05d5\u05d9 PIN +This\ device\ has\ no\ biometric\ enrolled.\ Set\ an\ unlock\ PIN\ instead.=\u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05dc\u05d0 \u05d4\u05d5\u05d2\u05d3\u05e8 \u05d0\u05de\u05e6\u05e2\u05d9 \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9. \u05d4\u05d2\u05d3\u05d9\u05e8\u05d5 \u05d1\u05de\u05e7\u05d5\u05dd \u05d6\u05d0\u05ea PIN \u05dc\u05e9\u05d7\u05e8\u05d5\u05e8 \u05e0\u05e2\u05d9\u05dc\u05d4. +Without\ a\ biometric\ or\ a\ PIN\ the\ lock\ asks\ for\ your\ password.=\u05dc\u05dc\u05d0 \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4 \u05d0\u05d5 PIN \u05d4\u05e0\u05e2\u05d9\u05dc\u05d4 \u05ea\u05d1\u05e7\u05e9 \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05db\u05dd. +Remove\ PIN=\u05d4\u05e1\u05e8\u05ea PIN +Let\ your\ biometric\ unlock\ Bastion=\u05dc\u05d0\u05e4\u05e9\u05e8 \u05dc\u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4 \u05dc\u05e9\u05d7\u05e8\u05e8 \u05d0\u05ea \u05e0\u05e2\u05d9\u05dc\u05ea Bastion +New\ PIN=PIN \u05d7\u05d3\u05e9 +The\ same\ PIN\ again=\u05d0\u05d5\u05ea\u05d5 PIN \u05e9\u05d5\u05d1 +The\ two\ PINs\ are\ not\ the\ same.=\u05e9\u05e0\u05d9 \u05d4-PIN \u05d0\u05d9\u05e0\u05dd \u05d6\u05d4\u05d9\u05dd. +Choose\ 6\ to\ 12\ digits\ that\ are\ not\ a\ simple\ run.=\u05d1\u05d7\u05e8\u05d5 6 \u05e2\u05d3 12 \u05e1\u05e4\u05e8\u05d5\u05ea \u05e9\u05d0\u05d9\u05e0\u05df \u05e8\u05e6\u05e3 \u05e4\u05e9\u05d5\u05d8. +No\ biometric\ or\ PIN\ is\ set\ up\ on\ this\ device,\ so\ the\ way\ back\ in\ is\ your\ password.=\u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05dc\u05d0 \u05d4\u05d5\u05d2\u05d3\u05e8\u05d5 \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4 \u05d0\u05d5 PIN, \u05d5\u05dc\u05db\u05df \u05d4\u05d3\u05e8\u05da \u05d7\u05d6\u05e8\u05d4 \u05e4\u05e0\u05d9\u05de\u05d4 \u05d4\u05d9\u05d0 \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05db\u05dd. +Signing\ in\ again\ sets\ this\ device\ up\ from\ the\ start\:\ its\ payment\ key,\ biometric\ and\ PIN\ are\ removed.=\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05de\u05d2\u05d3\u05d9\u05e8\u05d4 \u05d0\u05ea \u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05de\u05d4\u05d4\u05ea\u05d7\u05dc\u05d4: \u05de\u05e4\u05ea\u05d7 \u05d4\u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd, \u05d4\u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4 \u05d5\u05d4-PIN \u05e9\u05dc\u05d5 \u05d9\u05d5\u05e1\u05e8\u05d5. +Bastion\ locks\ itself\ when\ you\ leave\ it.\ With\ this\ on,\ the\ phone\ keeps\ the\ key\ to\ your\ session\ and\ payments\ where\ only\ your\ biometric\ releases\ it.\ The\ device\ passcode\ is\ never\ accepted.=Bastion \u05e0\u05e0\u05e2\u05dc \u05de\u05e2\u05e6\u05de\u05d5 \u05db\u05e9\u05e2\u05d5\u05d6\u05d1\u05d9\u05dd \u05d0\u05d5\u05ea\u05d5. \u05db\u05e9\u05d6\u05d4 \u05e4\u05d5\u05e2\u05dc, \u05d4\u05d8\u05dc\u05e4\u05d5\u05df \u05e9\u05d5\u05de\u05e8 \u05d0\u05ea \u05d4\u05de\u05e4\u05ea\u05d7 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05d5\u05dc\u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05e9\u05dc\u05db\u05dd \u05d1\u05de\u05e7\u05d5\u05dd \u05e9\u05e8\u05e7 \u05d4\u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4 \u05e9\u05dc\u05db\u05dd \u05de\u05e9\u05d7\u05e8\u05e8\u05ea. \u05e7\u05d5\u05d3 \u05d4\u05d2\u05d9\u05e9\u05d4 \u05e9\u05dc \u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05dc\u05e2\u05d5\u05dc\u05dd \u05d0\u05d9\u05e0\u05d5 \u05de\u05ea\u05e7\u05d1\u05dc. +For\ when\ a\ biometric\ is\ not\ available\ or\ you\ would\ rather\ not\ use\ one\:\ {0}\ to\ {1}\ digits,\ not\ your\ card\ PIN.\ {2}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys\ on\ this\ device.=\u05dc\u05de\u05e7\u05e8\u05d4 \u05e9\u05d0\u05d9\u05df \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4 \u05d6\u05de\u05d9\u05e0\u05d4 \u05d0\u05d5 \u05e9\u05d0\u05ea\u05dd \u05de\u05e2\u05d3\u05d9\u05e4\u05d9\u05dd \u05e9\u05dc\u05d0 \u05dc\u05d4\u05e9\u05ea\u05de\u05e9 \u05d1\u05d4: {0} \u05e2\u05d3 {1} \u05e1\u05e4\u05e8\u05d5\u05ea, \u05dc\u05d0 \u05d4-PIN \u05e9\u05dc \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 \u05e9\u05dc\u05db\u05dd. {2} \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea \u05e9\u05d2\u05d5\u05d9\u05d9\u05dd \u05d1\u05e8\u05e6\u05e3 \u05de\u05d5\u05d7\u05e7\u05d9\u05dd \u05d0\u05ea \u05d4\u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4. +Opens\ the\ lock\ when\ a\ biometric\ cannot.\ {0}\ wrong\ tries\ erase\ the\ keys\ on\ this\ device.=\u05e4\u05d5\u05ea\u05d7 \u05d0\u05ea \u05d4\u05e0\u05e2\u05d9\u05dc\u05d4 \u05db\u05e9\u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4 \u05d0\u05d9\u05e0\u05d4 \u05d9\u05db\u05d5\u05dc\u05d4. {0} \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea \u05e9\u05d2\u05d5\u05d9\u05d9\u05dd \u05de\u05d5\u05d7\u05e7\u05d9\u05dd \u05d0\u05ea \u05d4\u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4. +Unlock\ first.=\u05e9\u05d7\u05e8\u05e8\u05d5 \u05ea\u05d7\u05d9\u05dc\u05d4 \u05d0\u05ea \u05d4\u05e0\u05e2\u05d9\u05dc\u05d4. +This\ device\ could\ not\ keep\ a\ biometric-bound\ key.=\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05dc\u05d0 \u05d4\u05e6\u05dc\u05d9\u05d7 \u05dc\u05e9\u05de\u05d5\u05e8 \u05de\u05e4\u05ea\u05d7 \u05d4\u05e7\u05e9\u05d5\u05e8 \u05dc\u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4. +This\ device\ has\ no\ biometric-bound\ key\ store.=\u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05d0\u05d9\u05df \u05d0\u05d7\u05e1\u05d5\u05df \u05dc\u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05d4\u05e7\u05e9\u05d5\u05e8\u05d9\u05dd \u05dc\u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4. +Biometric\ setup\ was\ cancelled.=\u05d4\u05d2\u05d3\u05e8\u05ea \u05d4\u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4 \u05d1\u05d5\u05d8\u05dc\u05d4. +No\ PIN\ is\ set\ on\ this\ device.=\u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05dc\u05d0 \u05d4\u05d5\u05d2\u05d3\u05e8 PIN. +The\ PIN\ vault\ is\ damaged.=\u05db\u05e1\u05e4\u05ea \u05d4-PIN \u05e4\u05d2\u05d5\u05de\u05d4. +The\ PIN\ vault\ could\ not\ be\ read.=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05e7\u05e8\u05d5\u05d0 \u05d0\u05ea \u05db\u05e1\u05e4\u05ea \u05d4-PIN. +The\ PIN\ could\ not\ be\ saved.=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05e9\u05de\u05d5\u05e8 \u05d0\u05ea \u05d4-PIN. +The\ old\ PIN\ could\ not\ be\ removed.=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05d4\u05e1\u05d9\u05e8 \u05d0\u05ea \u05d4-PIN \u05d4\u05d9\u05e9\u05df. +This\ device\ has\ no\ secure\ storage\ for\ the\ PIN.=\u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05d0\u05d9\u05df \u05d0\u05d7\u05e1\u05d5\u05df \u05de\u05d0\u05d5\u05d1\u05d8\u05d7 \u05dc-PIN. +App\ key=\u05de\u05e4\u05ea\u05d7 \u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 +Refresh\ token=\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05e8\u05e2\u05e0\u05d5\u05df +App\ lock=\u05e0\u05e2\u05d9\u05dc\u05ea \u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 +Biometrics=\u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05d4 +A\ signed-off\ copy\ for\ an\ external\ auditor\:\ the\ file,\ who\ made\ it\ and\ when,\ and\ its\ SHA-256\ so\ it\ can\ be\ checked\ later.=\u05e2\u05d5\u05ea\u05e7 \u05d7\u05ea\u05d5\u05dd \u05dc\u05de\u05d1\u05e7\u05e8 \u05d7\u05d9\u05e6\u05d5\u05e0\u05d9: \u05d4\u05e7\u05d5\u05d1\u05e5, \u05de\u05d9 \u05d4\u05e4\u05d9\u05e7 \u05d0\u05d5\u05ea\u05d5 \u05d5\u05de\u05ea\u05d9, \u05d5\u05d4-SHA-256 \u05e9\u05dc\u05d5 \u05dc\u05d1\u05d3\u05d9\u05e7\u05d4 \u05de\u05d0\u05d5\u05d7\u05e8\u05ea. +Access\ log=\u05d9\u05d5\u05de\u05df \u05d2\u05d9\u05e9\u05d4 +Action=\u05e4\u05e2\u05d5\u05dc\u05d4 +Actor=\u05de\u05d1\u05e6\u05e2 +Actual=\u05d1\u05e4\u05d5\u05e2\u05dc +Adjustment=\u05d4\u05ea\u05d0\u05de\u05d4 +All\ entries=\u05db\u05dc \u05d4\u05e4\u05e7\u05d5\u05d3\u05d5\u05ea +Amount\ (negative\ to\ debit)=\u05e1\u05db\u05d5\u05dd (\u05e9\u05dc\u05d9\u05dc\u05d9 \u05dc\u05d7\u05d9\u05d5\u05d1) +Approvals=\u05d0\u05d9\u05e9\u05d5\u05e8\u05d9\u05dd +Approve=\u05d0\u05e9\u05e8 +Approved=\u05d0\u05d5\u05e9\u05e8 +As\ of\ {0}=\u05e0\u05db\u05d5\u05df \u05dc-{0} +Asset=\u05e0\u05db\u05e1 +Assets=\u05e0\u05db\u05e1\u05d9\u05dd +Assets\ \=\ liabilities\ +\ equity=\u05e0\u05db\u05e1\u05d9\u05dd = \u05d4\u05ea\u05d7\u05d9\u05d9\u05d1\u05d5\u05d9\u05d5\u05ea + \u05d4\u05d5\u05df +Audit=\u05d1\u05d9\u05e7\u05d5\u05e8\u05ea +Audit\ chain=\u05e9\u05e8\u05e9\u05e8\u05ea \u05d1\u05d9\u05e7\u05d5\u05e8\u05ea +Audit\ trail=\u05e0\u05ea\u05d9\u05d1 \u05d1\u05d9\u05e7\u05d5\u05e8\u05ea +Audit\ trail\ of\ this\ entry=\u05e0\u05ea\u05d9\u05d1 \u05d4\u05d1\u05d9\u05e7\u05d5\u05e8\u05ea \u05e9\u05dc \u05e4\u05e7\u05d5\u05d3\u05d4 \u05d6\u05d5 +Awaiting\ approval=\u05de\u05de\u05ea\u05d9\u05df \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8 +Balance=\u05d9\u05ea\u05e8\u05d4 +Balance\ after=\u05d9\u05ea\u05e8\u05d4 \u05dc\u05d0\u05d7\u05e8 \u05de\u05db\u05df +Balance\ sheet=\u05de\u05d0\u05d6\u05df +Balance,\ 30\ days=\u05d9\u05ea\u05e8\u05d4, 30 \u05d9\u05d5\u05dd +Balanced=\u05de\u05d0\u05d5\u05d6\u05df +Breaks=\u05e4\u05e2\u05e8\u05d9\u05dd +Broken\ at\ \#{0}=\u05e0\u05e9\u05d1\u05e8\u05d4 \u05d1-#{0} +CSV=CSV +Capital=\u05d4\u05d5\u05df +Class=\u05e1\u05d5\u05d2 +Close\ period=\u05e1\u05d2\u05d5\u05e8 \u05ea\u05e7\u05d5\u05e4\u05d4 +Close\ the\ books\ through\ now=\u05e1\u05d2\u05d5\u05e8 \u05d0\u05ea \u05d4\u05e1\u05e4\u05e8\u05d9\u05dd \u05e2\u05d3 \u05e2\u05db\u05e9\u05d9\u05d5 +Closed\ at=\u05e0\u05e1\u05d2\u05e8 \u05d1 +Closed\ by=\u05e0\u05e1\u05d2\u05e8 \u05e2\u05dc \u05d9\u05d3\u05d9 +Closed\ through=\u05e0\u05e1\u05d2\u05e8 \u05e2\u05d3 +Closing\ a\ period\ moves\ revenue\ and\ expense\ into\ retained\ earnings\ and\ locks\ every\ entry\ up\ to\ its\ date.=\u05e1\u05d2\u05d9\u05e8\u05ea \u05ea\u05e7\u05d5\u05e4\u05d4 \u05de\u05e2\u05d1\u05d9\u05e8\u05d4 \u05d4\u05db\u05e0\u05e1\u05d5\u05ea \u05d5\u05d4\u05d5\u05e6\u05d0\u05d5\u05ea \u05dc\u05e2\u05d5\u05d3\u05e4\u05d9\u05dd \u05d5\u05e0\u05d5\u05e2\u05dc\u05ea \u05db\u05dc \u05e4\u05e7\u05d5\u05d3\u05d4 \u05e2\u05d3 \u05ea\u05d0\u05e8\u05d9\u05db\u05d4. +Closing\ balance=\u05d9\u05ea\u05e8\u05ea \u05e1\u05d2\u05d9\u05e8\u05d4 +Closing\ entry=\u05e4\u05e7\u05d5\u05d3\u05ea \u05e1\u05d2\u05d9\u05e8\u05d4 +Control=\u05d7\u05e9\u05d1\u05d5\u05df \u05d1\u05e7\u05e8\u05d4 +Could\ not\ save\ the\ file.=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05e9\u05de\u05d5\u05e8 \u05d0\u05ea \u05d4\u05e7\u05d5\u05d1\u05e5. +Credit\ (Cr)=\u05d6\u05db\u05d5\u05ea (\u05d6) +Credit\ to\ your\ account=\u05d6\u05d9\u05db\u05d5\u05d9 \u05dc\u05d7\u05e9\u05d1\u05d5\u05e0\u05da +Crypto\ withdrawal=\u05de\u05e9\u05d9\u05db\u05ea \u05e7\u05e8\u05d9\u05e4\u05d8\u05d5 +Date=\u05ea\u05d0\u05e8\u05d9\u05da +Debit\ (Dr)=\u05d7\u05d5\u05d1\u05d4 (\u05d7) +Debit\ from\ your\ account=\u05d7\u05d9\u05d5\u05d1 \u05de\u05d7\u05e9\u05d1\u05d5\u05e0\u05da +Decide=\u05d4\u05d7\u05dc\u05d8 +Description=\u05ea\u05d9\u05d0\u05d5\u05e8 +Difference=\u05d4\u05e4\u05e8\u05e9 +Dollar\ balance,\ 30\ days=\u05d9\u05ea\u05e8\u05d4 \u05d1\u05d3\u05d5\u05dc\u05e8\u05d9\u05dd, 30 \u05d9\u05d5\u05dd +Dollar\ volume,\ 30\ days=\u05d4\u05d9\u05e7\u05e3 \u05d1\u05d3\u05d5\u05dc\u05e8\u05d9\u05dd, 30 \u05d9\u05d5\u05dd +Each\ control\ account\ against\ the\ sum\ of\ its\ subledger.\ A\ difference\ is\ a\ break\ and\ is\ shown\ in\ red.=\u05db\u05dc \u05d7\u05e9\u05d1\u05d5\u05df \u05d1\u05e7\u05e8\u05d4 \u05de\u05d5\u05dc \u05e1\u05db\u05d5\u05dd \u05db\u05e8\u05d8\u05e1\u05ea \u05d4\u05de\u05e9\u05e0\u05d4 \u05e9\u05dc\u05d5. \u05d4\u05e4\u05e8\u05e9 \u05d4\u05d5\u05d0 \u05e4\u05e2\u05e8 \u05d5\u05de\u05d5\u05e6\u05d2 \u05d1\u05d0\u05d3\u05d5\u05dd. +Each\ event's\ hash\ covers\ its\ own\ fields\ and\ the\ hash\ before\ it,\ so\ changing\ any\ row\ breaks\ every\ link\ after\ it.=\u05d4\u05d2\u05d9\u05d1\u05d5\u05d1 \u05e9\u05dc \u05db\u05dc \u05d0\u05d9\u05e8\u05d5\u05e2 \u05de\u05db\u05e1\u05d4 \u05d0\u05ea \u05e9\u05d3\u05d5\u05ea\u05d9\u05d5 \u05d5\u05d0\u05ea \u05d4\u05d2\u05d9\u05d1\u05d5\u05d1 \u05e9\u05dc\u05e4\u05e0\u05d9\u05d5, \u05db\u05da \u05e9\u05e9\u05d9\u05e0\u05d5\u05d9 \u05e9\u05d5\u05e8\u05d4 \u05e9\u05d5\u05d1\u05e8 \u05db\u05dc \u05d7\u05d5\u05dc\u05d9\u05d4 \u05d0\u05d7\u05e8\u05d9\u05d4. +Entries=\u05e4\u05e7\u05d5\u05d3\u05d5\u05ea +Entry=\u05e4\u05e7\u05d5\u05d3\u05d4 +Entry\ id,\ account\ id\ or\ e-mail=\u05de\u05d6\u05d4\u05d4 \u05e4\u05e7\u05d5\u05d3\u05d4, \u05de\u05d6\u05d4\u05d4 \u05d7\u05e9\u05d1\u05d5\u05df \u05d0\u05d5 \u05d3\u05d5\u05d0"\u05dc +Equity=\u05d4\u05d5\u05df \u05e2\u05e6\u05de\u05d9 +Every\ control\ account\ agrees\ with\ its\ subledger.=\u05db\u05dc \u05d7\u05e9\u05d1\u05d5\u05df \u05d1\u05e7\u05e8\u05d4 \u05ea\u05d5\u05d0\u05dd \u05d0\u05ea \u05db\u05e8\u05d8\u05e1\u05ea \u05d4\u05de\u05e9\u05e0\u05d4 \u05e9\u05dc\u05d5. +Every\ entry,\ newest\ first.\ Debits\ and\ credits\ of\ an\ entry\ sum\ to\ the\ same\ amount\ in\ each\ currency.\ Tap\ an\ entry\ for\ its\ legs.=\u05db\u05dc \u05d4\u05e4\u05e7\u05d5\u05d3\u05d5\u05ea, \u05d4\u05d7\u05d3\u05e9\u05d4 \u05e8\u05d0\u05e9\u05d5\u05e0\u05d4. \u05d4\u05d7\u05d5\u05d1\u05d4 \u05d5\u05d4\u05d6\u05db\u05d5\u05ea \u05e9\u05dc \u05e4\u05e7\u05d5\u05d3\u05d4 \u05e9\u05d5\u05d5\u05d9\u05dd \u05d1\u05db\u05dc \u05de\u05d8\u05d1\u05e2. \u05d4\u05e7\u05e9 \u05e2\u05dc \u05e4\u05e7\u05d5\u05d3\u05d4 \u05dc\u05e4\u05d9\u05e8\u05d5\u05d8. +Every\ event\ that\ names\ the\ subject,\ oldest\ first.=\u05db\u05dc \u05d4\u05d0\u05d9\u05e8\u05d5\u05e2\u05d9\u05dd \u05e9\u05de\u05d6\u05db\u05d9\u05e8\u05d9\u05dd \u05d0\u05ea \u05d4\u05e0\u05d5\u05e9\u05d0, \u05d4\u05d9\u05e9\u05df \u05e8\u05d0\u05e9\u05d5\u05df. +Every\ read\ of\ the\ books\ and\ the\ audit\ log\ by\ staff\ is\ itself\ an\ audit\ event.=\u05db\u05dc \u05e7\u05e8\u05d9\u05d0\u05d4 \u05e9\u05dc \u05d4\u05e1\u05e4\u05e8\u05d9\u05dd \u05d5\u05d9\u05d5\u05de\u05df \u05d4\u05d1\u05d9\u05e7\u05d5\u05e8\u05ea \u05e2\u05dc \u05d9\u05d3\u05d9 \u05d4\u05e6\u05d5\u05d5\u05ea \u05d4\u05d9\u05d0 \u05d1\u05e2\u05e6\u05de\u05d4 \u05d0\u05d9\u05e8\u05d5\u05e2 \u05d1\u05d9\u05e7\u05d5\u05e8\u05ea. +Evidence=\u05e8\u05d0\u05d9\u05d5\u05ea +Expected=\u05e6\u05e4\u05d5\u05d9 +Expenses=\u05d4\u05d5\u05e6\u05d0\u05d5\u05ea +General\ ledger=\u05e1\u05e4\u05e8 \u05e8\u05d0\u05e9\u05d9 +Hash=\u05d2\u05d9\u05d1\u05d5\u05d1 +Head=\u05e8\u05d0\u05e9 +Head\ hash=\u05d2\u05d9\u05d1\u05d5\u05d1 \u05d4\u05e8\u05d0\u05e9 +In\ dollars=\u05d1\u05d3\u05d5\u05dc\u05e8\u05d9\u05dd +Income\ statement=\u05d3\u05d5\u05d7 \u05e8\u05d5\u05d5\u05d7 \u05d5\u05d4\u05e4\u05e1\u05d3 +Initiated\ by=\u05d9\u05d6\u05dd +Integrity\ checks=\u05d1\u05d3\u05d9\u05e7\u05d5\u05ea \u05e9\u05dc\u05de\u05d5\u05ea +JSON=JSON +Journal=\u05d9\u05d5\u05de\u05df +Journal\ entries=\u05e4\u05e7\u05d5\u05d3\u05d5\u05ea \u05d9\u05d5\u05de\u05df +Journal\ entries\ a\ day=\u05e4\u05e7\u05d5\u05d3\u05d5\u05ea \u05d1\u05d9\u05d5\u05dd +Journal\ entry\ {0}=\u05e4\u05e7\u05d5\u05d3\u05ea \u05d9\u05d5\u05de\u05df {0} +Ledger\ entry=\u05e4\u05e7\u05d5\u05d3\u05d4 \u05d1\u05e1\u05e4\u05e8 +Liabilities=\u05d4\u05ea\u05d7\u05d9\u05d9\u05d1\u05d5\u05d9\u05d5\u05ea +Links\ checked=\u05d7\u05d5\u05dc\u05d9\u05d5\u05ea \u05e9\u05e0\u05d1\u05d3\u05e7\u05d5 +Made\ by\ {0},\ {1}=\u05d4\u05d5\u05e4\u05e7 \u05e2\u05dc \u05d9\u05d3\u05d9 {0}, {1} +Maker=\u05d9\u05d5\u05d6\u05dd +Maker-checker\:\ an\ adjustment\ or\ a\ reversal\ raised\ by\ one\ administrator\ is\ posted\ only\ when\ another\ approves\ it.=\u05e2\u05d9\u05e7\u05e8\u05d5\u05df \u05d0\u05e8\u05d1\u05e2 \u05e2\u05d9\u05e0\u05d9\u05d9\u05dd: \u05d4\u05ea\u05d0\u05de\u05d4 \u05d0\u05d5 \u05d1\u05d9\u05d8\u05d5\u05dc \u05e9\u05d9\u05d6\u05dd \u05de\u05e0\u05d4\u05dc \u05e0\u05e8\u05e9\u05de\u05d9\u05dd \u05e8\u05e7 \u05db\u05e9\u05de\u05e0\u05d4\u05dc \u05d0\u05d7\u05e8 \u05de\u05d0\u05e9\u05e8. +Memo=\u05d4\u05e2\u05e8\u05d4 +Money\ in\ and\ out,\ 14\ days=\u05db\u05e1\u05e3 \u05e0\u05db\u05e0\u05e1 \u05d5\u05d9\u05d5\u05e6\u05d0, 14 \u05d9\u05d5\u05dd +Net\ income=\u05e8\u05d5\u05d5\u05d7 \u05e0\u05e7\u05d9 +No\ breaks=\u05d0\u05d9\u05df \u05e4\u05e2\u05e8\u05d9\u05dd +No\ entries\ yet.=\u05d0\u05d9\u05df \u05e2\u05d3\u05d9\u05d9\u05df \u05e4\u05e7\u05d5\u05d3\u05d5\u05ea. +No\ events.=\u05d0\u05d9\u05df \u05d0\u05d9\u05e8\u05d5\u05e2\u05d9\u05dd. +No\ period\ has\ been\ closed\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05e0\u05e1\u05d2\u05e8\u05d4 \u05d0\u05e3 \u05ea\u05e7\u05d5\u05e4\u05d4. +No\ postings\ to\ this\ account\ in\ this\ currency.=\u05d0\u05d9\u05df \u05e8\u05d9\u05e9\u05d5\u05de\u05d9\u05dd \u05d1\u05d7\u05e9\u05d1\u05d5\u05df \u05d6\u05d4 \u05d1\u05de\u05d8\u05d1\u05e2 \u05d6\u05d4. +Nothing\ in\ this\ section.=\u05d0\u05d9\u05df \u05d3\u05d1\u05e8 \u05d1\u05e1\u05e2\u05d9\u05e3 \u05d6\u05d4. +Nothing\ waiting.=\u05d0\u05d9\u05df \u05d3\u05d1\u05e8 \u05de\u05de\u05ea\u05d9\u05df. +Opening\ balance=\u05d9\u05ea\u05e8\u05ea \u05e4\u05ea\u05d9\u05d7\u05d4 +Order\ hold=\u05d4\u05e7\u05e4\u05d0\u05ea \u05e4\u05e7\u05d5\u05d3\u05d4 +Order\ release=\u05e9\u05d7\u05e8\u05d5\u05e8 \u05e4\u05e7\u05d5\u05d3\u05d4 +Out\ of\ balance=\u05dc\u05d0 \u05de\u05d0\u05d5\u05d6\u05df +Overview=\u05e1\u05e7\u05d9\u05e8\u05d4 +Owner=\u05d1\u05e2\u05dc\u05d9\u05dd +Pending=\u05de\u05de\u05ea\u05d9\u05df +Period=\u05ea\u05e7\u05d5\u05e4\u05d4 +Period\ close=\u05e1\u05d2\u05d9\u05e8\u05ea \u05ea\u05e7\u05d5\u05e4\u05d4 +Posted=\u05e0\u05e8\u05e9\u05dd +Posting=\u05e8\u05d9\u05e9\u05d5\u05dd +Previous\ hash=\u05d2\u05d9\u05d1\u05d5\u05d1 \u05e7\u05d5\u05d3\u05dd +Price,\ 24\ hours=\u05de\u05d7\u05d9\u05e8, 24 \u05e9\u05e2\u05d5\u05ea +Problem=\u05d1\u05e2\u05d9\u05d4 +Raise\ an\ adjustment=\u05d9\u05d6\u05d5\u05dd \u05d4\u05ea\u05d0\u05de\u05d4 +Raised=\u05e0\u05d5\u05e6\u05e8 +Read-only=\u05e7\u05e8\u05d9\u05d0\u05d4 \u05d1\u05dc\u05d1\u05d3 +Reason=\u05e1\u05d9\u05d1\u05d4 +Reconciliation=\u05d4\u05ea\u05d0\u05de\u05ea \u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea +Reject=\u05d3\u05d7\u05d4 +Rejected=\u05e0\u05d3\u05d7\u05d4 +Request\ adjustment=\u05d1\u05e7\u05e9 \u05d4\u05ea\u05d0\u05de\u05d4 +Request\ reversal=\u05d1\u05e7\u05e9 \u05d1\u05d9\u05d8\u05d5\u05dc +Revenue=\u05d4\u05db\u05e0\u05e1\u05d5\u05ea +Reversal=\u05d1\u05d9\u05d8\u05d5\u05dc +Reverse\ entry\ {0}=\u05d1\u05d8\u05dc \u05e4\u05e7\u05d5\u05d3\u05d4 {0} +Reverse\ this\ entry=\u05d1\u05d8\u05dc \u05e4\u05e7\u05d5\u05d3\u05d4 \u05d6\u05d5 +Reversed\ by=\u05d1\u05d5\u05d8\u05dc \u05e2\u05dc \u05d9\u05d3\u05d9 +Reverses=\u05de\u05d1\u05d8\u05dc \u05d0\u05ea +SHA-256=SHA-256 +Saved=\u05e0\u05e9\u05de\u05e8 +Sent\ for\ approval.=\u05e0\u05e9\u05dc\u05d7 \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8. +Show\ as\ a\ T-account=\u05d4\u05e6\u05d2 \u05db\u05db\u05e8\u05d8\u05d9\u05e1 T +Show\ trail=\u05d4\u05e6\u05d2 \u05e0\u05ea\u05d9\u05d1 +Side=\u05e6\u05d3 +Staff\ member=\u05d0\u05d9\u05e9 \u05e6\u05d5\u05d5\u05ea +Statement=\u05d3\u05e3 \u05d7\u05e9\u05d1\u05d5\u05df +Status=\u05de\u05e6\u05d1 +Subject=\u05e0\u05d5\u05e9\u05d0 +Subledger=\u05db\u05e8\u05d8\u05e1\u05ea \u05de\u05e9\u05e0\u05d4 +The\ other\ side\ of\ this\ entry\ is\ in\ the\ bank's\ books;\ every\ entry's\ debits\ equal\ its\ credits.=\u05d4\u05e6\u05d3 \u05d4\u05e9\u05e0\u05d9 \u05e9\u05dc \u05e4\u05e7\u05d5\u05d3\u05d4 \u05d6\u05d5 \u05e0\u05de\u05e6\u05d0 \u05d1\u05e1\u05e4\u05e8\u05d9 \u05d4\u05d1\u05e0\u05e7; \u05d4\u05d7\u05d5\u05d1\u05d4 \u05e9\u05dc \u05db\u05dc \u05e4\u05e7\u05d5\u05d3\u05d4 \u05e9\u05d5\u05d5\u05d4 \u05dc\u05d6\u05db\u05d5\u05ea \u05e9\u05dc\u05d4. +Time=\u05d6\u05de\u05df +Total=\u05e1\u05d4"\u05db +Total\ assets=\u05e1\u05da \u05d4\u05e0\u05db\u05e1\u05d9\u05dd +Total\ credits=\u05e1\u05da \u05d6\u05db\u05d5\u05ea +Total\ debits=\u05e1\u05da \u05d7\u05d5\u05d1\u05d4 +Transaction=\u05ea\u05e0\u05d5\u05e2\u05d4 +Transfer=\u05d4\u05e2\u05d1\u05e8\u05d4 +Trial\ balance=\u05de\u05d0\u05d6\u05df \u05d1\u05d5\u05d7\u05df +Type=\u05e1\u05d5\u05d2 +Unavailable=\u05dc\u05d0 \u05d6\u05de\u05d9\u05df +Valued\ in\ dollars=\u05d1\u05e9\u05d5\u05d5\u05d9 \u05d3\u05d5\u05dc\u05e8\u05d9\u05dd +Volume\ ({0})=\u05d4\u05d9\u05e7\u05e3 ({0}) +Where\ your\ money\ is=\u05d0\u05d9\u05e4\u05d4 \u05d4\u05db\u05e1\u05e3 \u05e9\u05dc\u05da +Withdrawal=\u05de\u05e9\u05d9\u05db\u05d4 +Your\ password=\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da +credit=\u05d6\u05db\u05d5\u05ea +debit=\u05d7\u05d5\u05d1\u05d4 +{0}\ account,\ normal\ balance\ {1}.=\u05d7\u05e9\u05d1\u05d5\u05df {0}, \u05d9\u05ea\u05e8\u05d4 \u05e8\u05d2\u05d9\u05dc\u05d4 {1}. +{0}\ breaks={0} \u05e4\u05e2\u05e8\u05d9\u05dd +{0}\ raised\ by\ {1}={0} \u05e0\u05d5\u05e6\u05e8 \u05e2\u05dc \u05d9\u05d3\u05d9 {1} +{0}\ to\ {1}={0} \u05e2\u05d3 {1} +Showing\ the\ latest\ {0}\ of\ {1}\ postings.=\u05de\u05d5\u05e6\u05d2\u05d5\u05ea {0} \u05d4\u05e8\u05e9\u05d5\u05de\u05d5\u05ea \u05d4\u05d0\u05d7\u05e8\u05d5\u05e0\u05d5\u05ea \u05de\u05ea\u05d5\u05da {1}. +Cash\ in\ dollar\ accounts,\ 30\ days=\u05de\u05d6\u05d5\u05de\u05df \u05d1\u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea \u05d3\u05d5\u05dc\u05e8\u05d9\u05d9\u05dd, 30 \u05d9\u05d5\u05dd +error.account-already-has-authenticator-app=\u05dc\u05d7\u05e9\u05d1\u05d5\u05df \u05d4\u05d6\u05d4 \u05db\u05d1\u05e8 \u05d9\u05e9 \u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d9\u05ea \u05d0\u05d9\u05de\u05d5\u05ea +error.account-already-has-card=\u05dc\u05d7\u05e9\u05d1\u05d5\u05df \u05d4\u05d6\u05d4 \u05db\u05d1\u05e8 \u05d9\u05e9 \u05db\u05e8\u05d8\u05d9\u05e1 +error.account-frozen=\u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05d4\u05d6\u05d4 \u05de\u05d5\u05e7\u05e4\u05d0 +error.add-authenticator-app=\u05d4\u05d5\u05e1\u05d9\u05e4\u05d5 \u05ea\u05d7\u05d9\u05dc\u05d4 \u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d9\u05ea \u05d0\u05d9\u05de\u05d5\u05ea +error.address-already-list=\u05d4\u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d6\u05d5 \u05db\u05d1\u05e8 \u05e0\u05de\u05e6\u05d0\u05ea \u05d1\u05e8\u05e9\u05d9\u05de\u05d4 \u05e9\u05dc\u05db\u05dd +error.address-new-used=\u05d4\u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d6\u05d5 \u05d7\u05d3\u05e9\u05d4: \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05e9\u05ea\u05de\u05e9 \u05d1\u05d4 \u05d4\u05d7\u05dc \u05de-{0} +error.address-wrong-asset=\u05d4\u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d6\u05d5 \u05de\u05d9\u05d5\u05e2\u05d3\u05ea \u05dc-{0} +error.administrators=\u05dc\u05de\u05e0\u05d4\u05dc\u05d9\u05dd \u05d1\u05dc\u05d1\u05d3 +error.amount-cannot-zero=\u05e1\u05db\u05d5\u05dd \u05d0\u05d9\u05e0\u05d5 \u05d9\u05db\u05d5\u05dc \u05dc\u05d4\u05d9\u05d5\u05ea \u05d0\u05e4\u05e1 +error.amount-more-than-zero=\u05d4\u05e1\u05db\u05d5\u05dd \u05d7\u05d9\u05d9\u05d1 \u05dc\u05d4\u05d9\u05d5\u05ea \u05d2\u05d3\u05d5\u05dc \u05de\u05d0\u05e4\u05e1 +error.amount-too-large=\u05d4\u05e1\u05db\u05d5\u05dd \u05d4\u05d6\u05d4 \u05d2\u05d3\u05d5\u05dc \u05de\u05d3\u05d9 +error.both-accounts-hold-same-currency=\u05e9\u05e0\u05d9 \u05d4\u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea \u05d7\u05d9\u05d9\u05d1\u05d9\u05dd \u05dc\u05d4\u05d7\u05d6\u05d9\u05e7 \u05d1\u05d0\u05d5\u05ea\u05d5 \u05de\u05d8\u05d1\u05e2. \u05d4\u05e9\u05ea\u05de\u05e9\u05d5 \u05d1\u05d1\u05d5\u05e8\u05e1\u05d4 \u05dc\u05d4\u05de\u05e8\u05d4. +error.card-frozen=\u05d4\u05db\u05e8\u05d8\u05d9\u05e1 \u05d4\u05d6\u05d4 \u05de\u05d5\u05e7\u05e4\u05d0 +error.card-limit-range=\u05de\u05d2\u05d1\u05dc\u05ea \u05db\u05e8\u05d8\u05d9\u05e1 \u05d4\u05d9\u05d0 \u05d1\u05d9\u05df 0 \u05dc-{0} +error.cards-issued-checking-accounts=\u05db\u05e8\u05d8\u05d9\u05e1\u05d9\u05dd \u05de\u05d5\u05e0\u05e4\u05e7\u05d9\u05dd \u05dc\u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea \u05e2\u05d5"\u05e9 +error.choose-password-least-characters=\u05d1\u05d7\u05e8\u05d5 \u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc {0} \u05ea\u05d5\u05d5\u05d9\u05dd \u05dc\u05e4\u05d7\u05d5\u05ea +error.choose-two-different-accounts=\u05d1\u05d7\u05e8\u05d5 \u05e9\u05e0\u05d9 \u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea \u05e9\u05d5\u05e0\u05d9\u05dd +error.close-account-before-opening-another=\u05e1\u05d2\u05e8\u05d5 \u05d7\u05e9\u05d1\u05d5\u05df \u05dc\u05e4\u05e0\u05d9 \u05e4\u05ea\u05d9\u05d7\u05ea \u05d7\u05e9\u05d1\u05d5\u05df \u05e0\u05d5\u05e1\u05e3 +error.code-not-app-shows=\u05d4\u05e7\u05d5\u05d3 \u05d4\u05d6\u05d4 \u05d0\u05d9\u05e0\u05d5 \u05d4\u05e7\u05d5\u05d3 \u05e9\u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05e9\u05dc\u05db\u05dd \u05de\u05e6\u05d9\u05d2\u05d4 +error.coin-withdrawn-address=\u05e8\u05e7 \u05de\u05d8\u05d1\u05e2 \u05e0\u05de\u05e9\u05da \u05d0\u05dc \u05db\u05ea\u05d5\u05d1\u05ea +error.coins-withdrawn-wallet=\u05de\u05d8\u05d1\u05e2\u05d5\u05ea \u05e0\u05de\u05e9\u05db\u05d9\u05dd \u05de\u05d0\u05e8\u05e0\u05e7 +error.crypto-held-wallet-fiat-account=\u05e7\u05e8\u05d9\u05e4\u05d8\u05d5 \u05de\u05d5\u05d7\u05d6\u05e7 \u05d1\u05d0\u05e8\u05e0\u05e7, \u05db\u05e1\u05e3 \u05e8\u05d2\u05d9\u05dc \u05d1\u05d7\u05e9\u05d1\u05d5\u05df +error.customers-hold-accounts=\u05e8\u05e7 \u05dc\u05dc\u05e7\u05d5\u05d7\u05d5\u05ea \u05d9\u05e9 \u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea +error.device-not-attested=\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05d0\u05d9\u05e0\u05d5 \u05de\u05d0\u05d5\u05e9\u05e8 \u05d0\u05d5 \u05e9\u05d1\u05d5\u05e6\u05e2 \u05d1\u05d5 Root: \u05d4\u05d5\u05d0 \u05d9\u05db\u05d5\u05dc \u05dc\u05d4\u05e2\u05d1\u05d9\u05e8 \u05db\u05e1\u05e3 \u05e8\u05e7 \u05d1\u05d9\u05df \u05d4\u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea \u05e9\u05dc\u05db\u05dd +error.e-mail-already-has-account=\u05dc\u05d0\u05d9\u05de\u05d9\u05d9\u05dc \u05d4\u05d6\u05d4 \u05db\u05d1\u05e8 \u05d9\u05e9 \u05d7\u05e9\u05d1\u05d5\u05df +error.enter-password-add-device-turn=\u05d4\u05d6\u05d9\u05e0\u05d5 \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05de\u05db\u05e9\u05d9\u05e8, \u05d0\u05d5 \u05d4\u05e4\u05e2\u05d9\u05dc\u05d5 \u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d9\u05ea \u05d0\u05d9\u05de\u05d5\u05ea +error.account-locked=\u05d9\u05d5\u05ea\u05e8 \u05de\u05d3\u05d9 \u05e1\u05d9\u05e1\u05de\u05d0\u05d5\u05ea \u05e9\u05d2\u05d5\u05d9\u05d5\u05ea: \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e0\u05e2\u05d5\u05dc \u05dc\u05d6\u05de\u05df \u05de\u05d4 +error.sign-or-enter-password-revoke=\u05d0\u05e9\u05e8\u05d5 \u05d6\u05d0\u05ea \u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d0\u05d7\u05e8, \u05d0\u05d5 \u05d4\u05d6\u05d9\u05e0\u05d5 \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 +error.enter-password-add-app=\u05d4\u05d6\u05d9\u05e0\u05d5 \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d9\u05ea \u05d0\u05d9\u05de\u05d5\u05ea +error.entry-needs-two-sides=\u05e8\u05d9\u05e9\u05d5\u05dd \u05d6\u05e7\u05d5\u05e7 \u05dc\u05e9\u05e0\u05d9 \u05e6\u05d3\u05d3\u05d9\u05dd +error.idempotency-key-reused-order=\u05d4-Idempotency-Key \u05d4\u05d6\u05d4 \u05db\u05d1\u05e8 \u05e9\u05d9\u05de\u05e9 \u05dc\u05e4\u05e7\u05d5\u05d3\u05d4 \u05d0\u05d7\u05e8\u05ea +error.idempotency-key-reused=\u05d4-Idempotency-Key \u05d4\u05d6\u05d4 \u05db\u05d1\u05e8 \u05e9\u05d9\u05de\u05e9 \u05dc\u05d1\u05e7\u05e9\u05d4 \u05d0\u05d7\u05e8\u05ea +error.insufficient-funds=\u05d0\u05d9\u05df \u05de\u05e1\u05e4\u05d9\u05e7 \u05db\u05e1\u05e3 +error.key-ec-p-256-public=\u05d4\u05de\u05e4\u05ea\u05d7 \u05d7\u05d9\u05d9\u05d1 \u05dc\u05d4\u05d9\u05d5\u05ea \u05de\u05e4\u05ea\u05d7 \u05e6\u05d9\u05d1\u05d5\u05e8\u05d9 \u05de\u05e1\u05d5\u05d2 EC P-256 +error.limit-order-names-no-budget=\u05e4\u05e7\u05d5\u05d3\u05ea LIMIT \u05d0\u05d9\u05e0\u05d4 \u05de\u05e6\u05d9\u05d9\u05e0\u05ea \u05ea\u05e7\u05e6\u05d9\u05d1 +error.limit-order-needs-price=\u05e4\u05e7\u05d5\u05d3\u05ea LIMIT \u05d3\u05d5\u05e8\u05e9\u05ea \u05de\u05d7\u05d9\u05e8 +error.market-buy-names-budget=\u05e7\u05e0\u05d9\u05d9\u05ea MARKET \u05de\u05e6\u05d9\u05d9\u05e0\u05ea \u05e8\u05e7 \u05ea\u05e7\u05e6\u05d9\u05d1 +error.market-sell-names-quantity=\u05de\u05db\u05d9\u05e8\u05ea MARKET \u05de\u05e6\u05d9\u05d9\u05e0\u05ea \u05e8\u05e7 \u05db\u05de\u05d5\u05ea +error.more-than-crypto-movement-may=\u05d6\u05d4 \u05d9\u05d5\u05ea\u05e8 \u05de\u05de\u05d4 \u05e9\u05ea\u05e0\u05d5\u05e2\u05ea \u05e7\u05e8\u05d9\u05e4\u05d8\u05d5 \u05d0\u05d7\u05ea \u05e8\u05e9\u05d0\u05d9\u05ea \u05dc\u05d4\u05e2\u05d1\u05d9\u05e8 +error.more-than-payment-may-move=\u05d6\u05d4 \u05d9\u05d5\u05ea\u05e8 \u05de\u05de\u05d4 \u05e9\u05ea\u05e9\u05dc\u05d5\u05dd \u05d0\u05d7\u05d3 \u05e8\u05e9\u05d0\u05d9 \u05dc\u05d4\u05e2\u05d1\u05d9\u05e8 +error.move-money-between-own-accounts=\u05d4\u05e2\u05d1\u05d9\u05e8\u05d5 \u05d1\u05de\u05e7\u05d5\u05dd \u05d6\u05d0\u05ea \u05db\u05e1\u05e3 \u05d1\u05d9\u05df \u05d4\u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea \u05e9\u05dc\u05db\u05dd +error.name-account-pay-either-account=\u05e6\u05d9\u05d9\u05e0\u05d5 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05de\u05de\u05e0\u05d5 \u05de\u05e9\u05dc\u05de\u05d9\u05dd, \u05d5\u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05d0\u05d5 \u05d4\u05e0\u05de\u05e2\u05df \u05e9\u05de\u05e9\u05dc\u05de\u05d9\u05dd \u05dc\u05d5 +error.name-payee=\u05e6\u05d9\u05d9\u05e0\u05d5 \u05d0\u05ea \u05d4\u05e0\u05de\u05e2\u05df +error.name-too-long=\u05d4\u05e9\u05dd \u05d4\u05d6\u05d4 \u05d0\u05e8\u05d5\u05da \u05de\u05d3\u05d9 +error.needs-device-signature=\u05d6\u05d4 \u05d3\u05d5\u05e8\u05e9 \u05d7\u05ea\u05d9\u05de\u05d4 \u05de\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05e9\u05dc\u05db\u05dd: {0} +error.new-payee-receive-until-their=\u05e0\u05de\u05e2\u05df \u05d7\u05d3\u05e9 \u05d9\u05db\u05d5\u05dc \u05dc\u05e7\u05d1\u05dc \u05e8\u05e7 {0} \u05e2\u05d3 \u05ea\u05d5\u05dd \u05ea\u05e7\u05d5\u05e4\u05ea \u05d4\u05e6\u05d9\u05e0\u05d5\u05df \u05e9\u05dc\u05d5 +error.no-customer-has-address=\u05dc\u05d0\u05e3 \u05dc\u05e7\u05d5\u05d7 \u05d0\u05d9\u05df \u05d0\u05ea \u05d4\u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d6\u05d5 +error.no-such-account=\u05d0\u05d9\u05df \u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d6\u05d4 +error.no-such-address=\u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05ea \u05db\u05d6\u05d5 +error.no-such-asset=\u05d0\u05d9\u05df \u05e0\u05db\u05e1 \u05db\u05d6\u05d4 +error.no-such-card=\u05d0\u05d9\u05df \u05db\u05e8\u05d8\u05d9\u05e1 \u05db\u05d6\u05d4 +error.no-such-device=\u05d0\u05d9\u05df \u05de\u05db\u05e9\u05d9\u05e8 \u05db\u05d6\u05d4 +error.no-such-kind-account=\u05d0\u05d9\u05df \u05e1\u05d5\u05d2 \u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d6\u05d4 +error.no-such-market=\u05d0\u05d9\u05df \u05e9\u05d5\u05e7 \u05db\u05d6\u05d4 +error.no-such-order=\u05d0\u05d9\u05df \u05e4\u05e7\u05d5\u05d3\u05d4 \u05db\u05d6\u05d5 +error.no-such-passkey=\u05d0\u05d9\u05df \u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4 \u05db\u05d6\u05d4 +error.no-such-payee=\u05d0\u05d9\u05df \u05e0\u05de\u05e2\u05df \u05db\u05d6\u05d4 +error.not-address=\u05d6\u05d5 \u05d0\u05d9\u05e0\u05d4 \u05db\u05ea\u05d5\u05d1\u05ea +error.not-current-password=\u05d6\u05d5 \u05d0\u05d9\u05e0\u05d4 \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05ea \u05e9\u05dc\u05db\u05dd +error.not-e-mail-address=\u05d6\u05d5 \u05d0\u05d9\u05e0\u05d4 \u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05d9\u05de\u05d9\u05d9\u05dc +error.not-found=\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0 +error.nothing-register=\u05d0\u05d9\u05df \u05de\u05d4 \u05dc\u05e8\u05e9\u05d5\u05dd +error.open-checking-first=\u05e4\u05ea\u05d7\u05d5 \u05ea\u05d7\u05d9\u05dc\u05d4 \u05d7\u05e9\u05d1\u05d5\u05df \u05e2\u05d5"\u05e9 \u05d1-{0} +error.open-checking-savings-wallet-account=\u05e4\u05ea\u05d7\u05d5 \u05d7\u05e9\u05d1\u05d5\u05df CHECKING, SAVINGS \u05d0\u05d5 WALLET +error.open-wallet-first=\u05e4\u05ea\u05d7\u05d5 \u05ea\u05d7\u05d9\u05dc\u05d4 \u05d0\u05e8\u05e0\u05e7 {0} +error.order-already-status=\u05d4\u05e4\u05e7\u05d5\u05d3\u05d4 \u05d4\u05d6\u05d5 \u05db\u05d1\u05e8 {0} +error.order-buy-sell=\u05e4\u05e7\u05d5\u05d3\u05d4 \u05d4\u05d9\u05d0 BUY \u05d0\u05d5 SELL +error.order-limit-market=\u05e4\u05e7\u05d5\u05d3\u05d4 \u05d4\u05d9\u05d0 LIMIT \u05d0\u05d5 MARKET +error.order-too-large=\u05d4\u05e4\u05e7\u05d5\u05d3\u05d4 \u05d4\u05d6\u05d5 \u05d2\u05d3\u05d5\u05dc\u05d4 \u05de\u05d3\u05d9 +error.over-card-daily-limit=\u05d6\u05d4 \u05d7\u05d5\u05e8\u05d2 \u05de\u05d4\u05de\u05d2\u05d1\u05dc\u05d4 \u05d4\u05d9\u05d5\u05de\u05d9\u05ea \u05e9\u05dc \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 +error.password-cannot-e-mail-address=\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05db\u05dd \u05dc\u05d0 \u05d9\u05db\u05d5\u05dc\u05d4 \u05dc\u05d4\u05d9\u05d5\u05ea \u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d0\u05d9\u05de\u05d9\u05d9\u05dc \u05e9\u05dc\u05db\u05dd +error.password-too-long=\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05d4\u05d6\u05d5 \u05d0\u05e8\u05d5\u05db\u05d4 \u05de\u05d3\u05d9 +error.payee-already-list=\u05d4\u05e0\u05de\u05e2\u05df \u05d4\u05d6\u05d4 \u05db\u05d1\u05e8 \u05e0\u05de\u05e6\u05d0 \u05d1\u05e8\u05e9\u05d9\u05de\u05d4 \u05e9\u05dc\u05db\u05dd +error.payee-cannot-receive=\u05d4\u05e0\u05de\u05e2\u05df \u05d4\u05d6\u05d4 \u05d0\u05d9\u05e0\u05d5 \u05d9\u05db\u05d5\u05dc \u05dc\u05e7\u05d1\u05dc {0} +error.quantity-whole-number-unit-lots=\u05d4\u05db\u05de\u05d5\u05ea \u05d7\u05d9\u05d9\u05d1\u05ea \u05dc\u05d4\u05d9\u05d5\u05ea \u05de\u05e1\u05e4\u05e8 \u05e9\u05dc\u05dd \u05e9\u05dc \u05de\u05e0\u05d5\u05ea \u05d1\u05e0\u05d5\u05ea {0} \u05d9\u05d7\u05d9\u05d3\u05d5\u05ea +error.remove-address-before-adding-another=\u05d4\u05e1\u05d9\u05e8\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea \u05dc\u05e4\u05e0\u05d9 \u05d4\u05d5\u05e1\u05e4\u05ea \u05db\u05ea\u05d5\u05d1\u05ea \u05e0\u05d5\u05e1\u05e4\u05ea +error.remove-device-before-adding-another=\u05d4\u05e1\u05d9\u05e8\u05d5 \u05de\u05db\u05e9\u05d9\u05e8 \u05dc\u05e4\u05e0\u05d9 \u05d4\u05d5\u05e1\u05e4\u05ea \u05de\u05db\u05e9\u05d9\u05e8 \u05e0\u05d5\u05e1\u05e3 +error.remove-payee-before-adding-another=\u05d4\u05e1\u05d9\u05e8\u05d5 \u05e0\u05de\u05e2\u05df \u05dc\u05e4\u05e0\u05d9 \u05d4\u05d5\u05e1\u05e4\u05ea \u05e0\u05de\u05e2\u05df \u05e0\u05d5\u05e1\u05e3 +error.say-which-account-open=\u05e6\u05d9\u05d9\u05e0\u05d5 \u05d0\u05d9\u05d6\u05d4 \u05d7\u05e9\u05d1\u05d5\u05df \u05dc\u05e4\u05ea\u05d5\u05d7 +error.send-address-amount=\u05e9\u05dc\u05d7\u05d5 \u05d0\u05ea \u05d4\u05db\u05ea\u05d5\u05d1\u05ea \u05d5\u05d0\u05ea \u05d4\u05e1\u05db\u05d5\u05dd +error.send-address=\u05e9\u05dc\u05d7\u05d5 \u05d0\u05ea \u05d4\u05db\u05ea\u05d5\u05d1\u05ea +error.send-device-public-key=\u05e9\u05dc\u05d7\u05d5 \u05d0\u05ea \u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05e6\u05d9\u05d1\u05d5\u05e8\u05d9 \u05e9\u05dc \u05d4\u05de\u05db\u05e9\u05d9\u05e8 +error.send-idempotency-key-16-80=\u05e9\u05dc\u05d7\u05d5 Idempotency-Key \u05d1\u05d0\u05d5\u05e8\u05da 16 \u05e2\u05d3 80 \u05ea\u05d5\u05d5\u05d9\u05dd +error.send-limit=\u05e9\u05dc\u05d7\u05d5 \u05d0\u05ea \u05d4\u05de\u05d2\u05d1\u05dc\u05d4 +error.send-order=\u05e9\u05dc\u05d7\u05d5 \u05d0\u05ea \u05d4\u05e4\u05e7\u05d5\u05d3\u05d4 +error.sign-again-add-device-sign=\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5 \u05de\u05d7\u05d3\u05e9 \u05db\u05d3\u05d9 \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05de\u05db\u05e9\u05d9\u05e8: \u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05d7\u05d9\u05d9\u05d1\u05ea \u05dc\u05d4\u05d9\u05d5\u05ea \u05d1\u05ea \u05d3\u05e7\u05d5\u05ea \u05e1\u05e4\u05d5\u05e8\u05d5\u05ea +error.sign-in-first=\u05d9\u05e9 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05ea\u05d7\u05d9\u05dc\u05d4 +error.tell-us-name=\u05e1\u05e4\u05e8\u05d5 \u05dc\u05e0\u05d5 \u05d0\u05ea \u05e9\u05de\u05db\u05dd +error.there-no-price=\u05d0\u05d9\u05df \u05de\u05d7\u05d9\u05e8 \u05e2\u05d1\u05d5\u05e8 {0} +error.withdraw-address-list=\u05de\u05e9\u05db\u05d5 \u05e8\u05e7 \u05dc\u05db\u05ea\u05d5\u05d1\u05ea \u05e9\u05d1\u05e8\u05e9\u05d9\u05de\u05d4 \u05e9\u05dc\u05db\u05dd +error.would-pass-crypto-limit-day=\u05d6\u05d4 \u05d9\u05d7\u05e8\u05d5\u05d2 \u05de\u05d4\u05de\u05d2\u05d1\u05dc\u05d4 \u05d4\u05d9\u05d5\u05de\u05d9\u05ea \u05e9\u05dc\u05db\u05dd \u05dc\u05e7\u05e8\u05d9\u05e4\u05d8\u05d5 +error.would-pass-limit-day=\u05d6\u05d4 \u05d9\u05d7\u05e8\u05d5\u05d2 \u05de\u05d4\u05de\u05d2\u05d1\u05dc\u05d4 \u05d4\u05d9\u05d5\u05de\u05d9\u05ea \u05e9\u05dc\u05db\u05dd +Language=\u05e9\u05e4\u05d4 +Bastion\ cannot\ start=\u05d1\u05e1\u05d8\u05d9\u05d5\u05df \u05dc\u05d0 \u05d9\u05db\u05d5\u05dc \u05dc\u05e2\u05dc\u05d5\u05ea +The\ network\ protection\ could\ not\ be\ installed,\ so\ Bastion\ will\ not\ connect.\ Reinstall\ the\ app.=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05d4\u05ea\u05e7\u05d9\u05df \u05d0\u05ea \u05d4\u05d4\u05d2\u05e0\u05d4 \u05e2\u05dc \u05d4\u05e8\u05e9\u05ea, \u05d5\u05dc\u05db\u05df Bastion \u05dc\u05d0 \u05d9\u05ea\u05d7\u05d1\u05e8. \u05d4\u05ea\u05e7\u05df \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea \u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4. +A\ payee=\u05de\u05d5\u05d8\u05d1 +A\ vault\ opened\ by\ the\ PIN\ (PBKDF2,\ {0}\ rounds,\ peppered\ from\ the\ OS\ store);\ {1}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys=\u05db\u05e1\u05e4\u05ea \u05e9\u05e0\u05e4\u05ea\u05d7\u05ea \u05d1\u05e7\u05d5\u05d3 (PBKDF2, {0} \u05e1\u05d1\u05d1\u05d9\u05dd, \u05de\u05ea\u05d5\u05d1\u05dc\u05ea \u05de\u05de\u05d0\u05d2\u05e8 \u05d4\u05de\u05e2\u05e8\u05db\u05ea); {1} \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea \u05e9\u05d2\u05d5\u05d9\u05d9\u05dd \u05d1\u05e8\u05e6\u05e3 \u05de\u05d5\u05d7\u05e7\u05d9\u05dd \u05d0\u05ea \u05d4\u05de\u05e4\u05ea\u05d7\u05d5\u05ea +A\ vault\ opened\ by\ the\ PIN\ (PBKDF2,\ {0}\ rounds,\ peppered\ from\ the\ OS\ store);\ {1}\ wrong\ tries\ in\ a\ row\ erase\ the\ keys.\ Wrong\ tries\ so\ far\:\ {2}=\u05db\u05e1\u05e4\u05ea \u05e9\u05e0\u05e4\u05ea\u05d7\u05ea \u05d1\u05e7\u05d5\u05d3 (PBKDF2, {0} \u05e1\u05d1\u05d1\u05d9\u05dd, \u05de\u05ea\u05d5\u05d1\u05dc\u05ea \u05de\u05de\u05d0\u05d2\u05e8 \u05d4\u05de\u05e2\u05e8\u05db\u05ea); {1} \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea \u05e9\u05d2\u05d5\u05d9\u05d9\u05dd \u05d1\u05e8\u05e6\u05e3 \u05de\u05d5\u05d7\u05e7\u05d9\u05dd \u05d0\u05ea \u05d4\u05de\u05e4\u05ea\u05d7\u05d5\u05ea. \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea \u05e9\u05d2\u05d5\u05d9\u05d9\u05dd \u05e2\u05d3 \u05db\u05d4: {2} +Active=\u05e4\u05e2\u05d9\u05dc\u05d4 +After\ {0}s\ idle\ or\ {1}s\ in\ the\ background=\u05d0\u05d7\u05e8\u05d9 {0} \u05e9\u05e0\u05d9\u05d5\u05ea \u05dc\u05dc\u05d0 \u05e4\u05e2\u05d9\u05dc\u05d5\u05ea \u05d0\u05d5 {1} \u05e9\u05e0\u05d9\u05d5\u05ea \u05d1\u05e8\u05e7\u05e2 +AppShield\ engine\ {0}\:\ {1}=\u05de\u05e0\u05d5\u05e2 AppShield \u200f{0}: {1} +Available;\ turn\ it\ on\ in\ Settings=\u05d6\u05de\u05d9\u05df; \u05d4\u05e4\u05e2\u05d9\u05dc\u05d5 \u05d0\u05d5\u05ea\u05d5 \u05d1\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea +Biometric\ copy\ simulated\:\ a\ file\ gated\ by\ the\ simulator's\ Biometric\ Simulation\ menu=\u05e2\u05d5\u05ea\u05e7 \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9 \u05de\u05d3\u05d5\u05de\u05d4: \u05e7\u05d5\u05d1\u05e5 \u05d4\u05de\u05d5\u05d2\u05df \u05d1\u05ea\u05e4\u05e8\u05d9\u05d8 Biometric Simulation \u05e9\u05dc \u05d4\u05e1\u05d9\u05de\u05d5\u05dc\u05d8\u05d5\u05e8 +Can\ read\ the\ screen\:\ {0}=\u05d9\u05db\u05d5\u05dc\u05d9\u05dd \u05dc\u05e7\u05e8\u05d5\u05d0 \u05d0\u05ea \u05d4\u05de\u05e1\u05da: {0} +Cash\ withdrawal=\u05de\u05e9\u05d9\u05db\u05ea \u05de\u05d6\u05d5\u05de\u05df +Cloud\ build=\u05d1\u05e0\u05d9\u05d9\u05d4 \u05d1\u05e2\u05e0\u05df +Copied\ numbers\ are\ wiped\ after\ {0}s=\u05de\u05e1\u05e4\u05e8\u05d9\u05dd \u05e9\u05d4\u05d5\u05e2\u05ea\u05e7\u05d5 \u05e0\u05de\u05d7\u05e7\u05d9\u05dd \u05d0\u05d7\u05e8\u05d9 {0} \u05e9\u05e0\u05d9\u05d5\u05ea +Declared\ {0};\ applied\ by\ the\ build\ server=\u05d4\u05d5\u05e6\u05d4\u05e8 {0}; \u05e9\u05e8\u05ea \u05d4\u05d1\u05e0\u05d9\u05d9\u05d4 \u05de\u05d7\u05d9\u05dc \u05d0\u05d5\u05ea\u05d5 +Deposit=\u05d4\u05e4\u05e7\u05d3\u05d4 +Development\ server\ {0}\ is\ exempt\:\ plain\ HTTP\ to\ this\ machine\ only=\u05e9\u05e8\u05ea \u05d4\u05e4\u05d9\u05ea\u05d5\u05d7 {0} \u05e4\u05d8\u05d5\u05e8: HTTP \u05e8\u05d2\u05d9\u05dc \u05e8\u05e7 \u05dc\u05de\u05d7\u05e9\u05d1 \u05d4\u05d6\u05d4 +Enter\ an\ amount,\ like\ {0}.=\u05d4\u05d6\u05d9\u05e0\u05d5 \u05e1\u05db\u05d5\u05dd, \u05dc\u05de\u05e9\u05dc {0}. +Every\ entry\ balances\ and\ every\ balance\ matches\ its\ postings.=\u05db\u05dc \u05e8\u05d9\u05e9\u05d5\u05dd \u05de\u05d0\u05d5\u05d6\u05df \u05d5\u05db\u05dc \u05d9\u05ea\u05e8\u05d4 \u05ea\u05d5\u05d0\u05de\u05ea \u05d0\u05ea \u05d4\u05ea\u05e0\u05d5\u05e2\u05d5\u05ea \u05e9\u05dc\u05d4. +Every\ request\ is\ https;\ http\ is\ refused\ ({0}\ refused)=\u05db\u05dc \u05d1\u05e7\u05e9\u05d4 \u05d4\u05d9\u05d0 https; \u200fhttp \u05e0\u05d3\u05d7\u05d4 ({0} \u05e0\u05d3\u05d7\u05d5) +Keychain\ item\ with\ biometric\ access\ control\ (current\ set)\:\ released\ only\ after\ Face\ ID\ or\ Touch\ ID=\u05e4\u05e8\u05d9\u05d8 \u05d1\u05de\u05d7\u05d6\u05d9\u05e7 \u05d4\u05de\u05e4\u05ea\u05d7\u05d5\u05ea \u05e2\u05dd \u05d1\u05e7\u05e8\u05ea \u05d2\u05d9\u05e9\u05d4 \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9\u05ea (\u05d4\u05e1\u05d8 \u05d4\u05e0\u05d5\u05db\u05d7\u05d9): \u05de\u05e9\u05ea\u05d7\u05e8\u05e8 \u05e8\u05e7 \u05d0\u05d7\u05e8\u05d9 Face ID \u05d0\u05d5 Touch ID +Keystore\ key\ that\ requires\ user\ authentication\:\ released\ only\ after\ a\ strong\ biometric=\u05de\u05e4\u05ea\u05d7 Keystore \u05e9\u05d3\u05d5\u05e8\u05e9 \u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05e9\u05ea\u05de\u05e9: \u05de\u05e9\u05ea\u05d7\u05e8\u05e8 \u05e8\u05e7 \u05d0\u05d7\u05e8\u05d9 \u05d0\u05d9\u05de\u05d5\u05ea \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9 \u05d7\u05d6\u05e7 +Level\ {0},\ mapping\ {1}=\u05e8\u05de\u05d4 {0}, \u05de\u05d9\u05e4\u05d5\u05d9 {1} +Limit=\u05de\u05d5\u05d2\u05d1\u05dc\u05ea +Market=\u05e9\u05d5\u05e7 +My\ accounts=\u05d4\u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea \u05e9\u05dc\u05d9 +No\ such\ account=\u05d0\u05d9\u05df \u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d6\u05d4 +None\ enrolled\:\ the\ PIN\ or\ the\ password\ unlocks\ instead=\u05dc\u05d0 \u05e0\u05e8\u05e9\u05dd \u05d0\u05e3 \u05d0\u05d7\u05d3: \u05d4\u05e7\u05d5\u05d3 \u05d0\u05d5 \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e4\u05d5\u05ea\u05d7\u05d9\u05dd \u05d1\u05de\u05e7\u05d5\u05dd +Not\ in\ simulator=\u05dc\u05d0 \u05d1\u05e1\u05d9\u05de\u05d5\u05dc\u05d8\u05d5\u05e8 +Not\ set\:\ Settings,\ Unlock\ PIN=\u05dc\u05d0 \u05d4\u05d5\u05d2\u05d3\u05e8: \u05d4\u05d2\u05d3\u05e8\u05d5\u05ea, \u05e7\u05d5\u05d3 \u05e4\u05ea\u05d9\u05d7\u05d4 +On\ start,\ on\ return\ and\ when\ idle\:\ PIN=\u05d1\u05d4\u05e4\u05e2\u05dc\u05d4, \u05d1\u05d7\u05d6\u05e8\u05d4 \u05d5\u05d1\u05d7\u05d5\u05e1\u05e8 \u05e4\u05e2\u05d9\u05dc\u05d5\u05ea: \u05e7\u05d5\u05d3 +On\ start,\ on\ return\ and\ when\ idle\:\ biometric=\u05d1\u05d4\u05e4\u05e2\u05dc\u05d4, \u05d1\u05d7\u05d6\u05e8\u05d4 \u05d5\u05d1\u05d7\u05d5\u05e1\u05e8 \u05e4\u05e2\u05d9\u05dc\u05d5\u05ea: \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9 +On\ start,\ on\ return\ and\ when\ idle\:\ biometric,\ then\ PIN=\u05d1\u05d4\u05e4\u05e2\u05dc\u05d4, \u05d1\u05d7\u05d6\u05e8\u05d4 \u05d5\u05d1\u05d7\u05d5\u05e1\u05e8 \u05e4\u05e2\u05d9\u05dc\u05d5\u05ea: \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9, \u05d5\u05d0\u05d6 \u05e7\u05d5\u05d3 +On\ start,\ on\ return\ and\ when\ idle\:\ the\ password=\u05d1\u05d4\u05e4\u05e2\u05dc\u05d4, \u05d1\u05d7\u05d6\u05e8\u05d4 \u05d5\u05d1\u05d7\u05d5\u05e1\u05e8 \u05e4\u05e2\u05d9\u05dc\u05d5\u05ea: \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 +Only\ {0}=\u05e8\u05e7 {0} +Opened\ only\ by\ the\ unlock\ PIN=\u05e0\u05e4\u05ea\u05d7 \u05e8\u05e7 \u05d1\u05e7\u05d5\u05d3 \u05d4\u05e4\u05ea\u05d9\u05d7\u05d4 +Opened\ without\ asking\:\ set\ up\ a\ biometric\ or\ a\ PIN=\u05e0\u05e4\u05ea\u05d7 \u05d1\u05dc\u05d9 \u05dc\u05e9\u05d0\u05d5\u05dc: \u05d4\u05d2\u05d3\u05d9\u05e8\u05d5 \u05d0\u05d9\u05de\u05d5\u05ea \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9 \u05d0\u05d5 \u05e7\u05d5\u05d3 +P-256,\ registered\ as\ {0};\ signs\ every\ payment=P-256, \u05e8\u05e9\u05d5\u05dd \u05d1\u05ea\u05d5\u05e8 {0}; \u05d7\u05d5\u05ea\u05dd \u05e2\u05dc \u05db\u05dc \u05ea\u05e9\u05dc\u05d5\u05dd +Released\ by\ the\ platform\ after\ a\ biometric=\u05d4\u05e4\u05dc\u05d8\u05e4\u05d5\u05e8\u05de\u05d4 \u05de\u05e9\u05d7\u05e8\u05e8\u05ea \u05d0\u05d5\u05ea\u05d5 \u05d0\u05d7\u05e8\u05d9 \u05d0\u05d9\u05de\u05d5\u05ea \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9 +Sealed\ under\ the\ app\ key,\ which\ opens\ without\ asking\ until\ a\ biometric\ or\ PIN\ is\ set\ up=\u05d7\u05ea\u05d5\u05dd \u05d1\u05de\u05e4\u05ea\u05d7 \u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4, \u05e9\u05e0\u05e4\u05ea\u05d7 \u05d1\u05dc\u05d9 \u05dc\u05e9\u05d0\u05d5\u05dc \u05e2\u05d3 \u05e9\u05de\u05d5\u05d2\u05d3\u05e8\u05d9\u05dd \u05d0\u05d9\u05de\u05d5\u05ea \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9 \u05d0\u05d5 \u05e7\u05d5\u05d3 +Sealed\ under\ the\ app\ key\:\ unreadable\ until\ the\ lock\ is\ opened=\u05d7\u05ea\u05d5\u05dd \u05d1\u05de\u05e4\u05ea\u05d7 \u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4: \u05dc\u05d0 \u05e7\u05e8\u05d9\u05d0 \u05e2\u05d3 \u05e9\u05d4\u05e0\u05e2\u05d9\u05dc\u05d4 \u05e0\u05e4\u05ea\u05d7\u05ea +The\ audit\ chain\ is\ intact.=\u05e9\u05e8\u05e9\u05e8\u05ea \u05d4\u05d1\u05d9\u05e7\u05d5\u05e8\u05ea \u05e9\u05dc\u05de\u05d4. +The\ PIN\ could\ not\ be\ removed.=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05d4\u05e1\u05d9\u05e8 \u05d0\u05ea \u05d4\u05e7\u05d5\u05d3. +This\ device\ looks\ modified\:\ {0}.\ The\ bank\ will\ not\ move\ money\ from\ it.=\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4 \u05e0\u05e8\u05d0\u05d4 \u05de\u05e9\u05d5\u05e0\u05d4: {0}. \u05d4\u05d1\u05e0\u05e7 \u05dc\u05d0 \u05d9\u05e2\u05d1\u05d9\u05e8 \u05de\u05de\u05e0\u05d5 \u05db\u05e1\u05e3. +This\ platform\ has\ no\ biometric-bound\ store\:\ set\ up\ a\ PIN=\u05d1\u05e4\u05dc\u05d8\u05e4\u05d5\u05e8\u05de\u05d4 \u05d4\u05d6\u05d5 \u05d0\u05d9\u05df \u05de\u05d0\u05d2\u05e8 \u05d4\u05e7\u05e9\u05d5\u05e8 \u05dc\u05d0\u05d9\u05de\u05d5\u05ea \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9: \u05d4\u05d2\u05d3\u05d9\u05e8\u05d5 \u05e7\u05d5\u05d3 +Try\ again=\u05e0\u05e1\u05d5 \u05e9\u05d5\u05d1 +Unlock\ with\ a\ biometric\ is\ on=\u05e4\u05ea\u05d9\u05d7\u05d4 \u05d1\u05d0\u05d9\u05de\u05d5\u05ea \u05d1\u05d9\u05d5\u05de\u05d8\u05e8\u05d9 \u05e4\u05e2\u05d9\u05dc\u05d4 +Warning=\u05d0\u05d6\u05d4\u05e8\u05d4 +android.playIntegrity\={0}=android.playIntegrity={0} +ios.blockScreenshotsOnEnterBackground\={0}=ios.blockScreenshotsOnEnterBackground={0} +ios.detectJailbreak\={0}=ios.detectJailbreak={0} +{0}\ hosts\ pinned={0} \u05e9\u05e8\u05ea\u05d9\u05dd \u05de\u05d5\u05e6\u05de\u05d3\u05d9\u05dd +{0}\ hosts\ pinned,\ {1}\ mismatches\ refused={0} \u05e9\u05e8\u05ea\u05d9\u05dd \u05de\u05d5\u05e6\u05de\u05d3\u05d9\u05dd, {1} \u05d0\u05d9-\u05d4\u05ea\u05d0\u05de\u05d5\u05ea \u05e0\u05d3\u05d7\u05d5 +{0}\ hosts,\ version\ {1}={0} \u05e9\u05e8\u05ea\u05d9\u05dd, \u05d2\u05e8\u05e1\u05d4 {1} +{0}\ hosts,\ version\ {1}\ (refresh\ due)={0} \u05e9\u05e8\u05ea\u05d9\u05dd, \u05d2\u05e8\u05e1\u05d4 {1} (\u05e0\u05d3\u05e8\u05e9 \u05e8\u05e2\u05e0\u05d5\u05df) +error.price-outside-collar=\u05d4\u05de\u05d7\u05d9\u05e8 \u05d7\u05d9\u05d9\u05d1 \u05dc\u05d4\u05d9\u05d5\u05ea \u05d1\u05d8\u05d5\u05d5\u05d7 \u05e9\u05dc {0}% \u05de\u05de\u05d7\u05d9\u05e8 \u05d4\u05de\u05d3\u05d3 +error.no-such-entry=\u05d0\u05d9\u05df \u05e8\u05d9\u05e9\u05d5\u05dd \u05d9\u05d5\u05de\u05df \u05db\u05d6\u05d4 +error.entry-cannot-reversed=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d1\u05d8\u05dc \u05e8\u05d9\u05e9\u05d5\u05dd \u05d6\u05d4 +error.entry-already-reversed=\u05e8\u05d9\u05e9\u05d5\u05dd \u05d6\u05d4 \u05db\u05d1\u05e8 \u05d1\u05d5\u05d8\u05dc +error.period-closed=\u05ea\u05d0\u05e8\u05d9\u05da \u05d6\u05d4 \u05d1\u05ea\u05e7\u05d5\u05e4\u05d4 \u05e1\u05d2\u05d5\u05e8\u05d4 +error.period-end-past-last-close=\u05ea\u05e7\u05d5\u05e4\u05d4 \u05d7\u05d9\u05d9\u05d1\u05ea \u05dc\u05d4\u05e1\u05ea\u05d9\u05d9\u05dd \u05d1\u05e2\u05d1\u05e8, \u05d0\u05d7\u05e8\u05d9 \u05d4\u05ea\u05e7\u05d5\u05e4\u05d4 \u05e9\u05e0\u05e1\u05d2\u05e8\u05d4 \u05d0\u05d7\u05e8\u05d5\u05e0\u05d4 +error.four-eyes-second-administrator=\u05de\u05e0\u05d4\u05dc \u05e9\u05e0\u05d9 \u05d7\u05d9\u05d9\u05d1 \u05dc\u05d0\u05e9\u05e8 \u05d6\u05d0\u05ea +error.approval-not-pending=\u05d1\u05e7\u05e9\u05d4 \u05d6\u05d5 \u05db\u05d1\u05e8 \u05d0\u05d9\u05e0\u05d4 \u05de\u05de\u05ea\u05d9\u05e0\u05d4 \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8 +error.staff-only=\u05dc\u05e6\u05d5\u05d5\u05ea \u05d1\u05dc\u05d1\u05d3 +error.enter-password-confirm=\u05d4\u05d6\u05d9\u05e0\u05d5 \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8 +error.not-number=\u05d6\u05d4 \u05d0\u05d9\u05e0\u05d5 \u05de\u05e1\u05e4\u05e8 +error.name-entity=\u05e6\u05d9\u05d9\u05e0\u05d5 \u05d0\u05ea \u05d4\u05d9\u05e9\u05d5\u05ea \u05dc\u05de\u05e2\u05e7\u05d1 +error.format-csv-json=\u05d4\u05e4\u05d5\u05e8\u05de\u05d8 \u05d4\u05d5\u05d0 csv \u05d0\u05d5 json +Opening=\u05e4\u05ea\u05d9\u05d7\u05d4 +Closing=\u05e1\u05d2\u05d9\u05e8\u05d4 +Posted.=\u05e0\u05e8\u05e9\u05dd. +Above\ the\ approval\ threshold,\ a\ second\ administrator\ must\ approve\ it.=\u05de\u05e2\u05dc \u05e1\u05e3 \u05d4\u05d0\u05d9\u05e9\u05d5\u05e8, \u05de\u05e0\u05d4\u05dc \u05e0\u05d5\u05e1\u05e3 \u05d7\u05d9\u05d9\u05d1 \u05dc\u05d0\u05e9\u05e8. +Only\ the\ latest\ {0}\ postings\ are\ listed;\ the\ totals\ and\ balances\ cover\ them\ all.=\u05de\u05d5\u05e6\u05d2\u05d5\u05ea \u05e8\u05e7 {0} \u05d4\u05e8\u05e9\u05d5\u05de\u05d5\u05ea \u05d4\u05d0\u05d7\u05e8\u05d5\u05e0\u05d5\u05ea; \u05d4\u05e1\u05db\u05d5\u05de\u05d9\u05dd \u05d5\u05d4\u05d9\u05ea\u05e8\u05d5\u05ea \u05db\u05d5\u05dc\u05dc\u05d9\u05dd \u05d0\u05ea \u05db\u05d5\u05dc\u05df. +Receipt=\u05e7\u05d1\u05dc\u05d4 +Completed=\u05d4\u05d5\u05e9\u05dc\u05dd +Fee=\u05e2\u05de\u05dc\u05d4 +Category=\u05e7\u05d8\u05d2\u05d5\u05e8\u05d9\u05d4 +Show=\u05d4\u05e6\u05d2 +Columns=\u05e2\u05de\u05d5\u05d3\u05d5\u05ea +Card=\u05db\u05e8\u05d8\u05d9\u05e1 +Other=\u05d0\u05d7\u05e8 +Card\ details=\u05e4\u05e8\u05d8\u05d9 \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 +Virtual\ debit=\u05d7\u05d9\u05d5\u05d1 \u05d5\u05d9\u05e8\u05d8\u05d5\u05d0\u05dc\u05d9 +Expires=\u05ea\u05d5\u05e7\u05e3 +Cardholder=\u05d1\u05e2\u05dc \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 +{0}\ used\ today,\ {1}\ left={0} \u05e0\u05d5\u05e6\u05dc\u05d5 \u05d4\u05d9\u05d5\u05dd, \u05e0\u05d5\u05ea\u05e8\u05d5 {1} +Recent\ card\ payments=\u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05d0\u05d7\u05e8\u05d5\u05e0\u05d9\u05dd \u05d1\u05db\u05e8\u05d8\u05d9\u05e1 +This\ card\ has\ not\ been\ used\ yet.=\u05d1\u05db\u05e8\u05d8\u05d9\u05e1 \u05d4\u05d6\u05d4 \u05e2\u05d5\u05d3 \u05dc\u05d0 \u05e0\u05e2\u05e9\u05d4 \u05e9\u05d9\u05de\u05d5\u05e9. +Account\ ID=\u05de\u05d6\u05d4\u05d4 \u05d7\u05e9\u05d1\u05d5\u05df +No\ movements\ in\ this\ period.=\u05d0\u05d9\u05df \u05ea\u05e0\u05d5\u05e2\u05d5\u05ea \u05d1\u05ea\u05e7\u05d5\u05e4\u05d4 \u05d6\u05d5. +Card\ ID=\u05de\u05d6\u05d4\u05d4 \u05db\u05e8\u05d8\u05d9\u05e1 +Entry\ id=\u05de\u05d6\u05d4\u05d4 \u05e4\u05e7\u05d5\u05d3\u05d4 +Currency=\u05de\u05d8\u05d1\u05e2 +Books=\u05e1\u05e4\u05e8\u05d9\u05dd +All\ accounts=\u05db\u05dc \u05d4\u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea +As\ of=\u05e0\u05db\u05d5\u05df \u05dc +File=\u05e7\u05d5\u05d1\u05e5 +Made\ by=\u05e0\u05d5\u05e6\u05e8 \u05e2\u05dc \u05d9\u05d3\u05d9 +Generated=\u05d4\u05d5\u05e4\u05e7 +Print=\u05d4\u05d3\u05e4\u05e1\u05d4 +Printing\ is\ not\ available\ on\ this\ device.=\u05d4\u05d3\u05e4\u05e1\u05d4 \u05d0\u05d9\u05e0\u05d4 \u05d6\u05de\u05d9\u05e0\u05d4 \u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d6\u05d4. +Could\ not\ print\:\ {0}=\u05d4\u05d4\u05d3\u05e4\u05e1\u05d4 \u05e0\u05db\u05e9\u05dc\u05d4: {0} +Sent\ to\ the\ printer.=\u05e0\u05e9\u05dc\u05d7 \u05dc\u05de\u05d3\u05e4\u05e1\u05ea. +Page\ {0}\ of\ {1}=\u05e2\u05de\u05d5\u05d3 {0} \u05de\u05ea\u05d5\u05da {1} +{0}\ (continued)={0} (\u05d4\u05de\u05e9\u05da) +Device\ key\ ID=\u05de\u05d6\u05d4\u05d4 \u05de\u05e4\u05ea\u05d7 \u05d4\u05de\u05db\u05e9\u05d9\u05e8 +Passkey\ ID=\u05de\u05d6\u05d4\u05d4 \u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4 +Copy\ {0}=\u05d4\u05e2\u05ea\u05e7\u05ea {0} +Copied=\u05d4\u05d5\u05e2\u05ea\u05e7 +Or\ sign\ in\ with\ your\ e-mail\ and\ password.\ An\ account\ with\ a\ passkey\ takes\ its\ password\ only\ with\ a\ recovery\ code.=\u05d0\u05d5 \u05d4\u05ea\u05d7\u05d1\u05e8\u05d5 \u05e2\u05dd \u05d4\u05d3\u05d5\u05d0"\u05dc \u05d5\u05d4\u05e1\u05d9\u05e1\u05de\u05d4. \u05d7\u05e9\u05d1\u05d5\u05df \u05e2\u05dd \u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4 \u05de\u05e7\u05d1\u05dc \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e8\u05e7 \u05d9\u05d7\u05d3 \u05e2\u05dd \u05e7\u05d5\u05d3 \u05e9\u05d7\u05d6\u05d5\u05e8. +This\ account\ signs\ in\ with\ its\ passkey.\ Use\ the\ passkey,\ or\ ask\ the\ bank\ to\ help\ you\ recover\ the\ account.=\u05d7\u05e9\u05d1\u05d5\u05df \u05d6\u05d4 \u05de\u05ea\u05d7\u05d1\u05e8 \u05e2\u05dd \u05de\u05e4\u05ea\u05d7 \u05d4\u05d2\u05d9\u05e9\u05d4 \u05e9\u05dc\u05d5. \u05d4\u05e9\u05ea\u05de\u05e9\u05d5 \u05d1\u05de\u05e4\u05ea\u05d7, \u05d0\u05d5 \u05d1\u05e7\u05e9\u05d5 \u05de\u05d4\u05d1\u05e0\u05e7 \u05e2\u05d6\u05e8\u05d4 \u05d1\u05e9\u05d7\u05d6\u05d5\u05e8 \u05d4\u05d7\u05e9\u05d1\u05d5\u05df. +This\ account\ signs\ in\ with\ its\ passkey.\ Without\ it,\ enter\ one\ of\ your\ recovery\ codes.=\u05d7\u05e9\u05d1\u05d5\u05df \u05d6\u05d4 \u05de\u05ea\u05d7\u05d1\u05e8 \u05e2\u05dd \u05de\u05e4\u05ea\u05d7 \u05d4\u05d2\u05d9\u05e9\u05d4 \u05e9\u05dc\u05d5. \u05d1\u05dc\u05e2\u05d3\u05d9\u05d5, \u05d4\u05d6\u05d9\u05e0\u05d5 \u05d0\u05d7\u05d3 \u05de\u05e7\u05d5\u05d3\u05d9 \u05d4\u05e9\u05d7\u05d6\u05d5\u05e8 \u05e9\u05dc\u05db\u05dd. +Custody=\u05de\u05e9\u05de\u05d5\u05e8\u05ea +Held\ on\ chain\ {0}=\u05d1\u05e9\u05e8\u05e9\u05e8\u05ea {0} +Owed\ to\ customers\ {0}=\u05d7\u05d5\u05d1 \u05dc\u05dc\u05e7\u05d5\u05d7\u05d5\u05ea {0} +Hot\ wallet=\u05d0\u05e8\u05e0\u05e7 \u05d7\u05dd +In\ transit=\u05d1\u05d3\u05e8\u05da +Withdrawals\ not\ yet\ confirmed=\u05de\u05e9\u05d9\u05db\u05d5\u05ea \u05e9\u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05d0\u05d5\u05e9\u05e8\u05d5 +Receive\ crypto=\u05e7\u05d1\u05dc\u05ea \u05e7\u05e8\u05d9\u05e4\u05d8\u05d5 +Chain\ {0}=\u05e9\u05e8\u05e9\u05e8\u05ea {0} +Your\ {0}\ address=\u05db\u05ea\u05d5\u05d1\u05ea \u05d4-{0} \u05e9\u05dc\u05da +Credited\ after\ {0}\ confirmations,\ usually\ a\ few\ minutes.=\u05e0\u05d6\u05e7\u05e3 \u05d0\u05d7\u05e8\u05d9 {0} \u05d0\u05d9\u05e9\u05d5\u05e8\u05d9\u05dd, \u05d1\u05d3\u05e8\u05da \u05db\u05dc\u05dc \u05ea\u05d5\u05da \u05d3\u05e7\u05d5\u05ea \u05e1\u05e4\u05d5\u05e8\u05d5\u05ea. +On\ the\ chain=\u05d1\u05e9\u05e8\u05e9\u05e8\u05ea +Nothing\ has\ crossed\ the\ chain\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05e2\u05d1\u05e8 \u05d3\u05d1\u05e8 \u05d1\u05e9\u05e8\u05e9\u05e8\u05ea. +To\ {0}=\u05d0\u05dc {0} +Final=\u05e1\u05d5\u05e4\u05d9 +Failed=\u05e0\u05db\u05e9\u05dc +Confirming=\u05de\u05de\u05ea\u05d9\u05df \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8 +Sending=\u05e0\u05e9\u05dc\u05d7 +Set\ up\ a\ second\ factor=\u05d4\u05d2\u05d3\u05e8\u05ea \u05d2\u05d5\u05e8\u05dd \u05e9\u05e0\u05d9 +Open\ Settings=\u05e4\u05ea\u05d9\u05d7\u05ea \u05d4\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea +error.too-many-new-addresses=\u05d4\u05d5\u05e1\u05e4\u05ea \u05d0\u05ea \u05de\u05e1\u05e4\u05e8 \u05d4\u05db\u05ea\u05d5\u05d1\u05d5\u05ea \u05d4\u05de\u05e8\u05d1\u05d9 \u05dc\u05d9\u05d5\u05dd; \u05e0\u05e1\u05d5 \u05e9\u05d5\u05d1 \u05de\u05d7\u05e8 +error.second-factor-add-device=\u05d4\u05d2\u05d3\u05d9\u05e8\u05d5 \u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d9\u05ea \u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d5 \u05de\u05e4\u05ea\u05d7 \u05d2\u05d9\u05e9\u05d4 \u05d5\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5 \u05d0\u05d9\u05ea\u05d5 \u05db\u05d3\u05d9 \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05de\u05db\u05e9\u05d9\u05e8 +error.too-many-new-payees=\u05d4\u05d5\u05e1\u05e4\u05ea \u05d0\u05ea \u05de\u05e1\u05e4\u05e8 \u05d4\u05e0\u05de\u05e2\u05e0\u05d9\u05dd \u05d4\u05de\u05e8\u05d1\u05d9 \u05dc\u05d9\u05d5\u05dd; \u05e0\u05e1\u05d5 \u05e9\u05d5\u05d1 \u05de\u05d7\u05e8 diff --git a/scripts/bastion/common/src/test/java/com/codename1/io/rest/TestResponses.java b/scripts/bastion/common/src/test/java/com/codename1/io/rest/TestResponses.java new file mode 100644 index 00000000000..87a530c1d30 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codename1/io/rest/TestResponses.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codename1.io.rest; + +/// Answers as a generated client hands them over, for the tests: the +/// constructor is the package's own. +public final class TestResponses { + private TestResponses() { + } + + public static Response ok(T data) { + return new Response(200, data, "OK"); + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/money/AmountsTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/money/AmountsTest.java new file mode 100644 index 00000000000..8f4a220fe4b --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/money/AmountsTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.money; + +import com.codenameone.examples.bastion.api.AssetDto; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Amounts go to text and back exactly, without a `double` anywhere. +class AmountsTest { + /// The rows `GET /api/assets` answers for the seeded ledger (V2__ledger.sql). + @BeforeAll + static void serverAssets() { + Assets.replace(Arrays.asList( + new AssetDto("BTC", "Bitcoin", Assets.CRYPTO, 8), + new AssetDto("ETH", "Ether", Assets.CRYPTO, 9), + new AssetDto("EUR", "Euro", Assets.FIAT, 2), + new AssetDto("USD", "US Dollar", Assets.FIAT, 2))); + assertEquals("EUR", Assets.all()[0].code); + } + + @Test + void formatsWithTheAssetsDecimals() { + assertEquals("$1,234.56", Amounts.format(123456, "USD")); + assertEquals("$0.05", Amounts.format(5, "USD")); + assertEquals("-$12.00", Amounts.format(-1200, "USD")); + assertEquals("1,000,000.00 EUR", Amounts.format(100000000, "EUR")); + assertEquals("0.015 BTC", Amounts.format(1500000, "BTC")); + assertEquals("1.00 BTC", Amounts.format(100000000, "BTC")); + assertEquals("0.000000001 ETH", Amounts.format(1, "ETH")); + assertEquals("-9,223,372,036,854,775,808", Amounts.format(Long.MIN_VALUE, 0, 0)); + } + + @Test + void parsesExactlyOrRefuses() { + assertEquals(1050, Amounts.parse("10.5", "USD")); + assertEquals(120050, Amounts.parse("1,200.50", "USD")); + assertEquals(50, Amounts.parse(".5", "USD")); + assertEquals(1200, Amounts.parse(" 12 ", "USD")); + assertEquals(1, Amounts.parse("0.00000001", "BTC")); + assertEquals(Amounts.INVALID, Amounts.parse("0.001", "USD")); + assertEquals(Amounts.INVALID, Amounts.parse("-5", "USD")); + assertEquals(Amounts.INVALID, Amounts.parse("1.2.3", "USD")); + assertEquals(Amounts.INVALID, Amounts.parse("1e5", "USD")); + assertEquals(Amounts.INVALID, Amounts.parse(".", "USD")); + assertEquals(Amounts.INVALID, Amounts.parse("1.5,0", "USD")); + assertEquals(Amounts.INVALID, Amounts.parse("99999999999999999999", "USD")); + assertEquals(Amounts.INVALID, Amounts.parse("92233720368547758.08", "USD")); + assertEquals(Long.MAX_VALUE, Amounts.parse("92233720368547758.07", "USD")); + } + + @Test + void roundTripsThroughTheFieldForm() { + assertEquals("1.2345", Amounts.plain(123450000, "BTC")); + assertEquals(123450000, Amounts.parse(Amounts.plain(123450000, "BTC"), "BTC")); + assertEquals("1234.50", Amounts.plain(123450, "USD")); + } + + @Test + void valuesWithoutOverflowRoundingDown() { + // 1.5 BTC at $60,000.00 is $90,000.00. + assertEquals(9000000, Amounts.value(150000000, 8, 6000000)); + // One satoshi at $60,000.00 is 0.06 cents: rounds down to 0. + assertEquals(0, Amounts.value(1, 8, 6000000)); + // 10 million BTC at $60,000.00 -- q * p would overflow a long. + assertEquals(6000000L * 10000000L, Amounts.value(1000000000000000L, 8, 6000000)); + } + + /// A comma is a decimal separator in German, French and Spanish: "10,50" + /// there is ten fifty, and in English it is refused, never 1,050. + @Test + void readsAndWritesTheLocalesSeparators() { + try { + Amounts.useSeparators('.', ','); + assertEquals(Amounts.INVALID, Amounts.parse("10,50", "EUR")); + assertEquals(Amounts.INVALID, Amounts.parse("1,2,3", "EUR")); + assertEquals(Amounts.INVALID, Amounts.parse("1.234,56", "EUR")); + assertEquals(Amounts.INVALID, Amounts.parse("1234,567", "EUR")); + assertEquals(Amounts.INVALID, Amounts.parse(",500", "EUR")); + assertEquals(123456789, Amounts.parse("1,234,567.89", "EUR")); + + Amounts.useSeparators(',', '.'); + assertEquals(1050, Amounts.parse("10,50", "EUR")); + assertEquals(Amounts.INVALID, Amounts.parse("1,2,3", "EUR")); + assertEquals(123456, Amounts.parse("1.234,56", "EUR")); + assertEquals(Amounts.INVALID, Amounts.parse("10.50", "EUR")); + assertEquals("1.234,56 EUR", Amounts.format(123456, "EUR")); + assertEquals("1234,56", Amounts.plain(123456, "EUR")); + assertEquals(123456, Amounts.parse(Amounts.plain(123456, "EUR"), "EUR")); + + // French groups with a narrow no-break space; any space will do. + Amounts.useSeparators(',', '\u202f'); + assertEquals(123456, Amounts.parse("1 234,56", "EUR")); + assertEquals(123456, Amounts.parse("1\u00a0234,56", "EUR")); + assertEquals("1\u202f234,56 EUR", Amounts.format(123456, "EUR")); + } finally { + Amounts.useSeparators('.', ','); + } + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.java new file mode 100644 index 00000000000..75b65854c4a --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.net; + +import com.codenameone.examples.bastion.AppConfig; +import com.codename1.io.ConnectionRequest; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// What the network guard lets through. +class GuardTest { + @Test + void cleartextAndUnlistedHostsAreRefused() { + withDevelopment(() -> { + assertNull(Guard.refusal("http://localhost:8080/api/me")); + assertNull(Guard.refusal("http://127.0.0.1:8080/login")); + }); + assertNull(Guard.refusal("https://api.bastion.example/api/me")); + assertNull(Guard.refusal("https://API.Bastion.Example:443/api/me")); + assertNotNull(Guard.refusal("http://api.bastion.example/api/me")); + assertNotNull(Guard.refusal("https://evil.example/api/me")); + // The user-info trick: the host is what follows the '@'. + assertNotNull(Guard.refusal("https://api.bastion.example@evil.example/")); + assertNotNull(Guard.refusal("https://localhost.evil.example/")); + assertNotNull(Guard.refusal("ftp://api.bastion.example/")); + } + + @Test + void hostOf() { + assertEquals("api.bastion.example", AppConfig.hostOf("https://api.bastion.example/x?y")); + assertEquals("[::1]", AppConfig.hostOf("http://[::1]:8080/")); + assertNull(AppConfig.hostOf("javascript:alert(1)")); + } + + /// The host is whatever follows the LAST '@' of the authority: user info + /// can carry a ':' and look like a host and port of its own. + @Test + void userInfoCannotPassForTheHost() { + assertEquals("evil.example", AppConfig.hostOf("http://localhost:1@evil.example/")); + assertEquals("evil.example", AppConfig.hostOf("http://localhost@evil.example:8080/x")); + assertEquals("evil.example", AppConfig.hostOf("https://a@b@evil.example/")); + // A backslash ends the authority, as a browser reads it. + assertEquals("localhost", AppConfig.hostOf("http://localhost\\@evil.example/")); + assertEquals("localhost", AppConfig.hostOf("http://u:p@localhost:8080/?next=@evil.example")); + assertEquals("[::1]", AppConfig.hostOf("http://user@[::1]:8080/")); + assertNull(AppConfig.hostOf("http://@/")); + withDevelopment(() -> { + assertNotNull(Guard.refusal("http://localhost:1@evil.example/")); + assertNotNull(Guard.refusal("http://127.0.0.1:8080@evil.example/api")); + }); + } + + /// The development exemption exists only in a development run: an + /// installed app handed a loopback address does not get cleartext. + @Test + void cleartextToLoopbackOnlyInDevelopment() { + String was = System.clearProperty("bastion.server.url"); + try { + assertFalse(AppConfig.developmentAllowed()); + assertNotNull(Guard.refusal("http://localhost:8080/api/me")); + assertNotNull(Guard.refusal("http://10.0.2.2:8080/api/me")); + assertEquals(AppConfig.RELEASE_SERVER, AppConfig.serverUrl()); + } finally { + if (was != null) { + System.setProperty("bastion.server.url", was); + } + } + withDevelopment(() -> { + assertNull(Guard.refusal("http://localhost:8080/api/me")); + // The emulator's alias for the host is another machine. + assertNotNull(Guard.refusal("http://10.0.2.2:8080/api/me")); + }); + } + + /// A pinned host that presents no chain is refused, not let through. + @Test + void anEmptyChainFailsClosedForAPinnedHost() { + ConnectionRequest request = new ConnectionRequest(); + request.setUrl("https://api.bastion.example/api/me"); + Guard guard = new Guard(); + assertThrows(IOException.class, () -> guard.checkCertificates(request, + new ConnectionRequest.SSLCertificate[0])); + assertThrows(IOException.class, () -> guard.checkCertificates(request, null)); + } + + private static void withDevelopment(Runnable check) { + String was = System.getProperty("bastion.server.url"); + System.setProperty("bastion.server.url", "http://localhost:8080"); + try { + check.run(); + } finally { + if (was == null) { + System.clearProperty("bastion.server.url"); + } else { + System.setProperty("bastion.server.url", was); + } + } + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/NetTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/NetTest.java new file mode 100644 index 00000000000..4323f187079 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/NetTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.net; + +import com.codename1.io.rest.TestResponses; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Answers from an earlier session are dropped. +class NetTest { + /// A call made before the lock answers after it: neither its result nor + /// its failure reaches the screen that made it. + @Test + void anAnswerAfterTheLockIsDropped() { + final int[] calls = new int[1]; + com.codename1.util.OnComplete> callback = + Net.to(value -> calls[0]++, (status, message) -> calls[0]++); + Session.suspend(); + callback.completed(TestResponses.ok("balance")); + assertEquals(0, calls[0]); + + Net.to((String value) -> calls[0]++, (status, message) -> calls[0]++) + .completed(TestResponses.ok("balance")); + assertEquals(1, calls[0], "a call made after it still answers"); + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/StoredSessionTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/StoredSessionTest.java new file mode 100644 index 00000000000..b4954729e58 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/StoredSessionTest.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.net; + +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.io.Util; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.oidc.TokenStore; +import com.codename1.util.AsyncResource; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The start finds a saved session under the key the session itself saves +/// it with -- what decides between the lock and the sign-in form after a +/// restart or a browser reload. +class StoredSessionTest { + @BeforeAll + static void port() { + Util.setImplementation(new JavaSEPort()); + } + + /// Accepts every save, as the platform store does once the key is open. + static final class Accepting implements TokenStore { + @Override + public AsyncResource load(String key) { + AsyncResource r = new AsyncResource(); + r.complete(null); + return r; + } + + @Override + public AsyncResource save(String key, OidcTokens tokens) { + AsyncResource r = new AsyncResource(); + r.complete(Boolean.TRUE); + return r; + } + + @Override + public AsyncResource clear(String key) { + AsyncResource r = new AsyncResource(); + r.complete(Boolean.TRUE); + return r; + } + } + + @Test + void aSavedSessionIsFoundAndAClearedOneIsNot() { + TokenStore marked = new Session.Marked(new Accepting()); + String key = Session.storeKey("http://localhost:18080"); + marked.clear(key); + assertFalse(Session.hasStoredSession("http://localhost:18080")); + marked.save(key, null); + assertTrue(Session.hasStoredSession("http://localhost:18080"), "the start sees the session the sign-in saved"); + marked.clear(key); + assertFalse(Session.hasStoredSession("http://localhost:18080"), "and not one signed out of"); + } + + @Test + void aMarkerWithNothingBehindItIsDropped() { + TokenStore marked = new Session.Marked(new Accepting()); + String key = Session.storeKey("http://localhost:18081"); + marked.save(key, null); + assertTrue(Session.hasStoredSession("http://localhost:18081")); + // Unlocked and read: the store holds nothing, so the next start must + // go straight to the sign-in form rather than to the lock. + marked.load(key); + assertFalse(Session.hasStoredSession("http://localhost:18081"), "the stale marker is gone"); + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/KeysTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/KeysTest.java new file mode 100644 index 00000000000..26d3ee78633 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/KeysTest.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.security; + +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.io.Preferences; +import com.codename1.io.Util; +import com.codename1.security.BiometricError; +import com.codename1.security.BiometricException; +import com.codenameone.examples.bastion.api.Refusal; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The PIN rules and the refusal wire format: the parts of the lock that do +/// not need a device. +class KeysTest { + @Test + void pinsThatAreTooShortOrTooSimpleAreRefused() { + assertTrue(Keys.validPin("402913")); + assertTrue(Keys.validPin("402913551")); + assertFalse(Keys.validPin("40291"), "five digits"); + assertFalse(Keys.validPin("4029135510231"), "thirteen digits"); + assertFalse(Keys.validPin("111111"), "one digit repeated"); + assertFalse(Keys.validPin("123456"), "a run up"); + assertFalse(Keys.validPin("987654"), "a run down"); + assertFalse(Keys.validPin("12a456"), "not a digit"); + assertFalse(Keys.validPin(null)); + } + + @Test + void thePinIsPepperedBeforeTheKdf() { + // Two devices with the same PIN derive different vault keys. + assertFalse(new String(Keys.pinSecret("402913", "a")) + .equals(new String(Keys.pinSecret("402913", "b")))); + } + + @Test + void aRefusalCarriesItsCodeValuesAndEnglish() { + String body = Refusal.format("address-wrong-asset", "That address is for {0}", "BTC"); + assertEquals("(address-wrong-asset|BTC) That address is for BTC", body); + assertEquals("address-wrong-asset", Refusal.code(body)); + assertArrayEquals(new String[] {"BTC"}, Refusal.values(body)); + assertEquals("That address is for BTC", Refusal.english(body)); + assertEquals("Una direccion para BTC", + Refusal.fill("Una direccion para {0}", Refusal.values(body))); + + String plain = Refusal.format("no-such-card", "No such card"); + assertEquals("no-such-card", Refusal.code(plain)); + assertEquals(0, Refusal.values(plain).length); + + String two = Refusal.format("x", "{1} then {0}", "a", "b"); + assertEquals("b then a", Refusal.english(two)); + assertArrayEquals(new String[] {"a", "b"}, Refusal.values(two)); + + // Not a refusal: shown as it is. + assertNull(Refusal.code("Sign in first")); + assertNull(Refusal.code("(not a code) at all")); + assertEquals("Sign in first", Refusal.english("Sign in first")); + } + + private static MemoryStorage fresh() { + Util.setImplementation(new JavaSEPort()); + MemoryStorage store = new MemoryStorage(); + Keys.useStorage(store); + Keys.useVault(null); + Keys.wipe(); + return store; + } + + /// The next account on the device does not inherit the previous one's + /// key, nor its device key. + @Test + void anotherAccountStartsOver() { + fresh(); + Keys.startOver(); + assertFalse(Keys.claim("alice@bastion.example")); + byte[] alices = Keys.current().clone(); + assertFalse(Keys.claim("alice@bastion.example"), "the same account keeps its key"); + assertArrayEquals(alices, Keys.current()); + assertTrue(Keys.claim("bob@bastion.example")); + assertFalse(java.util.Arrays.equals(alices, Keys.current()), "a new key for Bob"); + Keys.wipe(); + } + + /// A failed refresh behind the lock must not wipe the keys: only the + /// user starts over. + @Test + void aSignInPromptWhileLockedDoesNotWipe() { + fresh(); + Keys.startOver(); + Preferences.set("bastion.keys.pinOn", true); + Lock.install(new Lock.Screens() { + @Override + public void showLocked() { + } + + @Override + public void showUnlocked(com.codename1.ui.Form previous) { + } + }); + Keys.lock(); + Lock.lockAtStart(); + try { + Keys.signedIn(); + assertTrue(Keys.exists()); + assertTrue(Keys.pinEnabled(), "the PIN copy is still there"); + } finally { + Lock.reset(); + Keys.wipe(); + } + } + + /// A biometric copy the OS revoked is removed, not left behind a flag + /// that no longer mentions it. + @Test + void aRevokedBiometricCopyIsRemoved() { + MemoryStorage store = fresh(); + Keys.startOver(); + store.prompted.put("bastion.keys.biometric", "dead"); + Preferences.set("bastion.keys.biometricOn", true); + Keys.lock(); + store.promptedReadError = new BiometricException(BiometricError.KEY_REVOKED); + final Boolean[] gone = new Boolean[1]; + Keys.unlockWithBiometric("test", () -> { }, revoked -> gone[0] = revoked); + assertEquals(Boolean.TRUE, gone[0]); + assertFalse(Keys.biometricEnabled()); + assertTrue(store.prompted.isEmpty(), "the revoked entry is gone"); + Keys.wipe(); + } + + /// Wiping removes the biometric copy whatever the flag says. + @Test + void aWipeRemovesEveryCopy() { + MemoryStorage store = fresh(); + Keys.startOver(); + store.prompted.put("bastion.keys.biometric", "orphan"); + final Boolean[] done = new Boolean[1]; + Keys.wipe().ready(ok -> done[0] = ok); + assertEquals(Boolean.TRUE, done[0]); + assertTrue(store.prompted.isEmpty()); + assertTrue(store.quiet.isEmpty()); + assertFalse(Keys.exists()); + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/MemoryStorage.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/MemoryStorage.java new file mode 100644 index 00000000000..60669ea6052 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/MemoryStorage.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.security; + +import com.codename1.security.SecureStorage; +import com.codename1.util.AsyncResource; + +import java.util.HashMap; +import java.util.Map; + +/// A secure store in memory, for the tests: the quiet tier and the prompting +/// (biometric) tier as two maps, and the prompting tier's read answering +/// `promptedReadError` when one is set. +final class MemoryStorage extends SecureStorage { + final Map quiet = new HashMap<>(); + final Map prompted = new HashMap<>(); + Throwable promptedReadError; + + @Override + public boolean set(String account, String value) { + quiet.put(account, value); + return true; + } + + @Override + public String get(String account) { + return quiet.get(account); + } + + @Override + public boolean remove(String account) { + return quiet.remove(account) != null; + } + + @Override + public AsyncResource get(String reason, String account) { + AsyncResource out = new AsyncResource<>(); + if (promptedReadError != null) { + out.error(promptedReadError); + } else { + out.complete(prompted.get(account)); + } + return out; + } + + @Override + public AsyncResource set(String reason, String account, String value) { + prompted.put(account, value); + AsyncResource out = new AsyncResource<>(); + out.complete(Boolean.TRUE); + return out; + } + + @Override + public AsyncResource remove(String reason, String account) { + AsyncResource out = new AsyncResource<>(); + out.complete(Boolean.valueOf(prompted.remove(account) != null)); + return out; + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/ProtectTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/ProtectTest.java new file mode 100644 index 00000000000..eeb4c9335c1 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/ProtectTest.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.security; + +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.io.Util; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The clipboard leftover rules: a read that answers nothing keeps the +/// record, a match clears, a mismatch forgets. +class ProtectTest { + @Test + void anUnreadableClipboardKeepsTheRecord() { + assertEquals(Protect.KEEP, Protect.decide("100:abc", null)); + } + + @Test + void aMatchClearsAndAMismatchForgets() { + assertEquals(Protect.CLEAR, Protect.decide("100:abc", "abc")); + assertEquals(Protect.FORGET, Protect.decide("100:abc", "xyz")); + } + + @Test + void nothingIsReadBeforeTheClearFallsDue() { + assertFalse(Protect.due("5000:abc", 4999)); + assertTrue(Protect.due("5000:abc", 5000)); + assertTrue(Protect.due("abc", 0), "a record without a time is due"); + } + + @Test + void theDigestIsKeyedAndTheKeyIsKept() { + Util.setImplementation(new JavaSEPort()); + Keys.useStorage(new MemoryStorage()); + byte[] key = Protect.digestKey(); + assertArrayEquals(key, Protect.digestKey()); + assertEquals(Protect.digest(key, "4029"), Protect.digest(key, "4029")); + assertNotEquals(Protect.digest(key, "4029"), Protect.digest(new byte[32], "4029")); + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/RequestSignerTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/RequestSignerTest.java new file mode 100644 index 00000000000..d81503358af --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/RequestSignerTest.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.security; + +import com.codenameone.examples.bastion.api.DeviceDto; +import com.codenameone.examples.bastion.api.SignedRequest; +import com.codenameone.examples.bastion.api.UserDto; +import com.codenameone.examples.bastion.net.Session; +import com.codename1.security.KeyGenerator; +import com.codename1.security.KeyPair; +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.io.Util; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The signature the app sends is the one the server checks. +/// +/// The vector below is also in the server's `SigningVectorTest`: the same +/// request, the same canonical text. If either side's form changes, one of the +/// two fails. +class RequestSignerTest { + static final String BODY = "{\"amount\":1500,\"memo\":\"rent\"}"; + static final String NONCE = "AAECAwQFBgcICQoLDA0ODw"; + static final long TS = 1767225600000L; + static final String KEY = "idem-0123456789abcdef"; + static final String CANONICAL = "POST|/api/accounts/a1/withdraw|" + + "27faf49a231437b9d9308db1799c1ab06d8665c087da17478d59acbefa20a125|" + + KEY + "|" + NONCE + "|" + TS; + + @Test + void canonicalFormMatchesTheServers() throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(BODY.getBytes("UTF-8")); + assertEquals(CANONICAL, SignedRequest.canonical("POST", "/api/accounts/a1/withdraw", + digest, KEY, NONCE, TS)); + // No Idempotency-Key: an empty field, never a missing one. + assertTrue(SignedRequest.canonical("GET", "/x", digest, null, NONCE, TS) + .contains("||" + NONCE + "|")); + // 16 bytes in base64url, the length RequestSigner.nonce() makes. + assertTrue(SignedRequest.nonceShaped(NONCE)); + } + + @Test + void headerVerifiesWithTheJdkAsTheServerDoes() throws Exception { + // The JavaSE port's crypto (the JDK's provider) without a Display, + // which would need a window: the tests run headless. + Util.setImplementation(new JavaSEPort()); + KeyPair pair = KeyGenerator.ec(256); + String header = RequestSigner.header("dev1", pair.getPrivateKey(), "POST", + "/api/accounts/a1/withdraw", KEY, BODY.getBytes("UTF-8"), NONCE, TS); + assertTrue(header.startsWith("v1;device=dev1;ts=" + TS + ";nonce=" + NONCE + ";sig="), + header); + byte[] jose = Base64.getUrlDecoder().decode(SignedRequest.field(header, "sig")); + assertEquals(64, jose.length); + + PublicKey key = KeyFactory.getInstance("EC").generatePublic( + new X509EncodedKeySpec(pair.getPublicKey().getEncoded())); + Signature verifier = Signature.getInstance("SHA256withECDSA"); + verifier.initVerify(key); + verifier.update(CANONICAL.getBytes("UTF-8")); + assertTrue(verifier.verify(der(jose))); + } + + /// A device key registered for one account never signs for another: the + /// next user on the device would otherwise sign under the previous user's + /// device id. + @Test + void aKeyRegisteredForAnotherAccountDoesNotSign() throws Exception { + Util.setImplementation(new JavaSEPort()); + Keys.useStorage(new MemoryStorage()); + Keys.useVault(null); + Keys.startOver(); + try { + Session.useUser(user("alice@bastion.example")); + KeyPair pair = KeyGenerator.ec(256); + DeviceKey.keep("alice@bastion.example", pair.getPrivateKey(), + new DeviceDto("dev-a", "test", false, true, true, 0L), device -> { }, + (status, message) -> { throw new AssertionError(message); }); + assertNotNull(RequestSigner.sign("POST", "/api/transfers", "k", null)); + + Session.useUser(user("bob@bastion.example")); + assertNull(RequestSigner.sign("POST", "/api/transfers", "k", null)); + Session.useUser(null); + assertNull(RequestSigner.sign("POST", "/api/transfers", "k", null)); + } finally { + Session.useUser(null); + Keys.wipe(); + } + } + + static UserDto user(String name) { + return new UserDto(name, name, true, false, false, 0, 0L, false); + } + + @Test + void joseRoundTripsShortAndPaddedIntegers() { + byte[] jose = new byte[64]; + jose[0] = (byte) 0x80; // r needs a leading zero in DER + jose[63] = 5; // s is one byte long + org.junit.jupiter.api.Assertions.assertArrayEquals(jose, RequestSigner.jose(der(jose))); + } + + /// JOSE `r || s` to the DER the JDK verifies. + static byte[] der(byte[] jose) { + byte[] r = new BigInteger(1, java.util.Arrays.copyOfRange(jose, 0, 32)).toByteArray(); + byte[] s = new BigInteger(1, java.util.Arrays.copyOfRange(jose, 32, 64)).toByteArray(); + byte[] out = new byte[6 + r.length + s.length]; + out[0] = 0x30; + out[1] = (byte) (4 + r.length + s.length); + out[2] = 0x02; + out[3] = (byte) r.length; + System.arraycopy(r, 0, out, 4, r.length); + out[4 + r.length] = 0x02; + out[5 + r.length] = (byte) s.length; + System.arraycopy(s, 0, out, 6 + r.length, s.length); + return out; + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/SecurityStatusTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/SecurityStatusTest.java new file mode 100644 index 00000000000..bd9beeab426 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/SecurityStatusTest.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.security; + +import org.junit.jupiter.api.Test; + +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// The Security Center reports the hardening hints the build really declares. +class SecurityStatusTest { + @Test + void declaredHintsMatchTheSettings() throws Exception { + Properties settings = new Properties(); + InputStream in = new FileInputStream("codenameone_settings.properties"); + try { + settings.load(in); + } finally { + in.close(); + } + for (String line : SecurityStatus.DECLARED_HINTS) { + int eq = line.indexOf('='); + String name = line.substring(0, eq); + assertEquals(line.substring(eq + 1), settings.getProperty("codename1.arg." + name), + name); + } + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/AccountFormKeyTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/AccountFormKeyTest.java new file mode 100644 index 00000000000..0f11e85bac0 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/AccountFormKeyTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.io.Util; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/// Cash in and out keeps one idempotency key across tries of one action. +class AccountFormKeyTest { + @Test + void aRetryOfTheSameActionReusesItsKey() { + Util.setImplementation(new JavaSEPort()); + AccountForm.forgetKey(); + String first = AccountForm.keyFor("a1|out|2500"); + // The answer was lost; the user opens the dialog again, same amount. + assertEquals(first, AccountForm.keyFor("a1|out|2500")); + String other = AccountForm.keyFor("a1|out|3000"); + assertNotEquals(first, other, "another amount is another action"); + AccountForm.forgetKey(); + assertNotEquals(other, AccountForm.keyFor("a1|out|3000"), "a success forgets it"); + AccountForm.forgetKey(); + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/BundleTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/BundleTest.java new file mode 100644 index 00000000000..84369980af1 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/BundleTest.java @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import org.junit.jupiter.api.Test; + +import java.io.FileInputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Every text the screens name has a line in `Bundle_en.properties`, so the +/// file is a complete list for a translator to work from. A string added to +/// the code without its line fails here, named. +/// +/// What is checked: every literal operand of a call that translates its +/// argument -- `Lang.tr`, the builders that translate theirs (`Ui.form`, +/// `Ui.secondary`, `Ui.segments(new String[] {...})`, `Ui.failed`...), a +/// failure callback (`onSucess`, `failed`) whose text a screen shows +/// translated, and a Security Center `Item` -- including each side of a +/// `?:` among them; the Security Center's group names; and the `return` +/// literals of a method whose result is handed to `Lang.tr` (`stateName`). +/// A sentence built of several literals is the writer's to add by hand, and +/// is better written as one pattern with `{0}`. +class BundleTest { + /// Where a translated argument list starts. + private static final Pattern CALL = Pattern.compile( + "(?:(? 0 && Character.isJavaIdentifierPart(args.charAt(at - 1))) { + String nested = arguments(args, at); + out.append("()"); + at += nested.length(); + } else { + out.append(c); + at++; + } + } + return out.toString(); + } + + /// Every English text `source` hands to a translation. + static List translated(String text) { + List out = new ArrayList<>(); + Matcher call = CALL.matcher(text); + while (call.find()) { + Matcher m = OPERAND.matcher(withoutNestedCalls(arguments(text, call.end() - 1))); + while (m.find()) { + out.add(m.group(1)); + } + } + // The Security Center's groups: `g = "Network";`. + Matcher group = Pattern.compile("(? { });\n" + + "Ui.secondary(x ? \"C\" : \"D\");\n" + + "Ui.failed(content, \"E\", back);\n" + + "secondary(\"F\");\n" + + "new Item(g, \"G\", ACTIVE, y ? \"H {0}\" : \"I\", v);\n" + + "g = \"J\";\n" + + "Lang.tr(stateName(s));\n" + + "static String stateName(int s) { if (s == 1) { return \"K\"; } return \"L\"; }\n" + + "new Item(g, \"M\", OFF, \"N {0}\", hint(\"not.a.key\"));\n"; + assertEquals(java.util.Arrays.asList("A", "B", "C", "D", "E", "F", "G", "H {0}", "I", "M", "N {0}", + "J", "K", "L"), translated(code)); + } + + @Test + void everyTranslatedLiteralHasAnEnglishLine() throws Exception { + Properties bundle = new Properties(); + try (InputStreamReader in = new InputStreamReader( + new FileInputStream("src/main/l10n/Bundle_en.properties"), StandardCharsets.UTF_8)) { + bundle.load(in); + } + List missing = new ArrayList<>(); + List sources; + try (Stream walk = Files.walk(Paths.get("src/main/java"))) { + sources = walk.filter(p -> p.toString().endsWith(".java")).sorted() + .collect(java.util.stream.Collectors.toList()); + } + for (Path source : sources) { + for (String literal : translated(new String(Files.readAllBytes(source), + StandardCharsets.UTF_8))) { + String key = literal.replace("\\\"", "\"").replace("\\\\", "\\"); + if (key.length() > 0 && !bundle.containsKey(key)) { + missing.add(source.getFileName() + ": " + key); + } + } + } + assertEquals("", String.join("\n", missing)); + } + + /// Every refusal the server words (`Refused.with` in the backend) has its + /// `error.` line, with the server's own English. + @Test + void everyServerRefusalHasALine() throws Exception { + Properties bundle = load("src/main/l10n/Bundle_en.properties"); + Pattern refused = Pattern.compile( + "Refused\\.with\\(\\s*\\d+,\\s*\"([^\"]+)\",\\s*\"((?:[^\"\\\\]|\\\\.)*)\""); + List missing = new ArrayList<>(); + int seen = 0; + try (Stream walk = Files.walk(Paths.get("../backend/src/main/java"))) { + for (Path source : (Iterable) walk.filter(p -> p.toString().endsWith(".java"))::iterator) { + Matcher m = refused.matcher(new String(Files.readAllBytes(source), StandardCharsets.UTF_8)); + while (m.find()) { + seen++; + String line = bundle.getProperty("error." + m.group(1)); + if (!m.group(2).equals(line)) { + missing.add(source.getFileName() + ": error." + m.group(1) + "=" + m.group(2)); + } + } + } + } + assertTrue(seen > 50, "found the server's refusals"); + assertEquals("", String.join("\n", missing)); + } + + /// Every translation has every English key, the same `{n}` places, and + /// no key English does not have. + @Test + void everyTranslationIsComplete() throws Exception { + Properties en = load("src/main/l10n/Bundle_en.properties"); + assertEquals(Lang.CODES.length > 1, true, "there are translations"); + List wrong = new ArrayList<>(); + for (String code : Lang.CODES) { + Properties tr = load("src/main/l10n/Bundle_" + code + ".properties"); + for (String key : en.stringPropertyNames()) { + String value = tr.getProperty(key); + if (value == null || value.length() == 0) { + wrong.add(code + " lacks " + key); + } else if (!places(value).equals(places(en.getProperty(key)))) { + wrong.add(code + " places differ in " + key); + } + } + for (String key : tr.stringPropertyNames()) { + if (!en.containsKey(key)) { + wrong.add(code + " has extra " + key); + } + } + } + assertEquals("", String.join("\n", wrong)); + } + + private static java.util.Set places(String text) { + java.util.Set out = new java.util.TreeSet<>(); + Matcher m = Pattern.compile("\\{\\d\\}").matcher(text); + while (m.find()) { + out.add(m.group()); + } + return out; + } + + private static Properties load(String file) throws Exception { + Properties bundle = new Properties(); + try (InputStreamReader in = new InputStreamReader(new FileInputStream(file), + StandardCharsets.UTF_8)) { + bundle.load(in); + } + return bundle; + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/PickerChoiceTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/PickerChoiceTest.java new file mode 100644 index 00000000000..43ffacace70 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/PickerChoiceTest.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Pickers choose by id: two things with one label stay two choices. +class PickerChoiceTest { + @Test + void repeatedLabelsBecomeDistinct() { + assertArrayEquals(new String[] {"Rent", "Rent (2)", "Gym", "Rent (3)"}, + Ui.distinct(new String[] {"Rent", "Rent", "Gym", "Rent"})); + } + + @Test + void idsAreFoundByValue() { + String[] ids = {"p1", "p2"}; + assertEquals(1, Ui.indexOf(ids, "p2")); + assertEquals(-1, Ui.indexOf(ids, null)); + assertEquals(-1, Ui.indexOf(ids, "Rent")); + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/PrintoutTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/PrintoutTest.java new file mode 100644 index 00000000000..c615522b539 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/PrintoutTest.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A report goes on paper as lines of text: the header, the table with its +/// numbers lined up at the end of their columns, the totals last. +class PrintoutTest { + /// 2026-03-04 05:06 UTC. + private static final long GENERATED = 1772600760000L; + + private static Printout statement() { + Printout p = new Printout("Statement", GENERATED) + .fact("Account", "Everyday - Checking - USD") + .fact("Period", "Mar 1 to Mar 4"); + p.section(null, new String[] {"Date", "Description", "Balance"}, new int[] {1, 2, 1}, + new boolean[] {false, false, true}) + .row("Mar 1", "Coffee", "$10.00") + .row("Mar 2", "A description far too long for the column it is printed in, " + + "which is cut", "$1,234.56") + .total("", "Total", "$1,244.56"); + return p.total("Closing balance", "$1,244.56"); + } + + @Test + void theHeaderNamesTheBankTheReportTheAccountThePeriodAndTheTime() { + List lines = statement().lines(); + assertEquals(Printout.BANK, lines.get(0)); + assertEquals("Statement", lines.get(1)); + assertTrue(lines.get(3).startsWith("Account:"), lines.get(3)); + assertTrue(lines.get(3).endsWith("Everyday - Checking - USD"), lines.get(3)); + assertTrue(lines.get(4).startsWith("Period:"), lines.get(4)); + assertTrue(lines.get(5).endsWith("2026-03-04 05:06 UTC"), lines.get(5)); + } + + @Test + void columnsLineUpAndNothingIsWiderThanThePage() { + List lines = statement().lines(); + int[] widths = Printout.widths(new int[] {1, 2, 1}, Printout.COLUMNS); + int numberEnd = Printout.COLUMNS; + int rows = 0; + for (String line : lines) { + assertTrue(line.length() <= Printout.COLUMNS, line); + if (line.endsWith("$10.00") || line.endsWith("$1,234.56")) { + // Numbers end at the page's edge: right aligned. + assertEquals(numberEnd, line.length(), line); + rows++; + } + } + assertEquals(2, rows); + assertEquals(Printout.COLUMNS, widths[0] + widths[1] + widths[2] + 2); + // A cell too long for its column is cut, and says so. + boolean cut = false; + for (String line : lines) { + cut |= line.indexOf("A description far") >= 0 && line.indexOf("..") > 0; + } + assertTrue(cut); + } + + @Test + void totalsComeLastAndAreSetApart() { + List lines = statement().lines(); + String last = lines.get(lines.size() - 1); + assertTrue(last.startsWith("Closing balance"), last); + assertTrue(last.endsWith("$1,244.56"), last); + assertEquals(Printout.COLUMNS, last.length()); + int total = -1; + for (int iter = 0; iter < lines.size(); iter++) { + if (lines.get(iter).indexOf("Total") >= 0) { + total = iter; + } + } + assertTrue(lines.get(total - 1).startsWith("-----"), "a rule above the table's total"); + } + + @Test + void aNoteRunsAcrossTheTable() { + Printout p = new Printout("Audit trail", GENERATED); + p.section("Events", new String[] {"#", "Action"}, new int[] {1, 3}, + new boolean[] {true, false}).note("No events."); + List lines = p.lines(); + assertTrue(lines.contains("Events")); + assertEquals("No events.", lines.get(lines.size() - 1)); + } + + private static Printout longStatement(int rows) { + Printout p = new Printout("Statement", GENERATED).fact("Account", "Everyday"); + Printout.Section s = p.section("Transactions", new String[] {"Date", "Description", "Balance"}, + new int[] {1, 2, 1}, new boolean[] {false, false, true}); + for (int iter = 0; iter < rows; iter++) { + s.row("Mar " + iter, "Row " + iter, "$" + iter + ".00"); + } + s.total("", "Total", "$1.00"); + return p.total("Closing balance", "$1.00"); + } + + @Test + void aLongReportBreaksIntoNumberedPagesThatRepeatTheTableHeader() { + Printout p = longStatement(60); + List pages = p.pages(20); + assertTrue(pages.size() > 3, "pages: " + pages.size()); + int rows = 0; + for (int iter = 0; iter < pages.size(); iter++) { + List lines = pages.get(iter).lines(); + assertTrue(lines.size() <= 20, "page " + iter + " has " + lines.size()); + String last = lines.get(lines.size() - 1); + assertTrue(last.trim().equals("Page " + (iter + 1) + " of " + pages.size()), last); + if (iter > 0) { + assertTrue(lines.get(0).startsWith(Printout.BANK), lines.get(0)); + if (lines.contains("Transactions (continued)")) { + boolean header = false; + for (String line : lines) { + header |= line.startsWith("Date") && line.indexOf("Balance") > 0; + } + assertTrue(header, "page " + iter + " repeats the header"); + } + } + for (String line : lines) { + if (line.matches("Mar \\d+ .*")) { + rows++; + } + } + } + assertEquals(60, rows, "every row is printed once"); + List end = pages.get(pages.size() - 1).lines(); + boolean closing = false; + for (String line : end) { + closing |= line.startsWith("Closing balance"); + } + assertTrue(closing, "the totals end the last page"); + } + + @Test + void aShortReportIsOnePage() { + assertEquals(1, statement().pages(60).size()); + } + + @Test + void thePdfHasAPagePerPictureAndAValidCrossReference() throws Exception { + PdfPages pdf = new PdfPages(595, 842); + pdf.add(new int[] {0xffffff, 0, 0xff0000, 0x00ff00}, 2, 2); + pdf.add(new int[] {0x123456}, 1, 1); + byte[] bytes = pdf.toBytes(); + String text = new String(bytes, "ISO-8859-1"); + assertTrue(text.startsWith("%PDF-1.4")); + assertTrue(text.indexOf("/Count 2") > 0); + assertTrue(text.endsWith("%%EOF\n")); + int xref = Integer.parseInt(text.substring(text.lastIndexOf("startxref\n") + 10, + text.lastIndexOf("\n%%EOF")).trim()); + assertTrue(text.startsWith("xref", xref)); + String[] entries = text.substring(xref).split("\n"); + for (int obj = 1; obj <= 8; obj++) { + int at = Integer.parseInt(entries[2 + obj].substring(0, 10)); + assertTrue(text.startsWith(obj + " 0 obj", at), "object " + obj); + } + } + + @Test + void theStrongestWayInIsOfferedFirst() { + assertEquals(SignInForm.PASSKEY, SignInForm.strongest(true, true)); + assertEquals(SignInForm.PASSKEY, SignInForm.strongest(true, false)); + assertEquals(SignInForm.DEVICE, SignInForm.strongest(false, true)); + assertEquals(SignInForm.PASSWORD, SignInForm.strongest(false, false)); + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/QrCodeTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/QrCodeTest.java new file mode 100644 index 00000000000..3994b4ca5bf --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/QrCodeTest.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.ui; + +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The encoder draws the same symbol as an independent one. +/// +/// `qr-vectors.txt` was written by the Python `segno` encoder (level M, byte +/// mode): for every version this supports, the matrix for each of the eight +/// masks (and the one segno picks by penalty, read past: see below). A wrong check byte, a misplaced +/// format bit or a different mask choice fails here, not at an authenticator +/// app that will not scan the code. +class QrCodeTest { + @Test + void matchesAReferenceEncoderForEveryVersionAndMask() throws Exception { + InputStream in = QrCodeTest.class.getResourceAsStream("/qr-vectors.txt"); + assertNotNull(in); + BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + int checked = 0; + StringBuilder failures = new StringBuilder(); + String header; + while ((header = reader.readLine()) != null && header.length() > 0) { + String[] parts = header.split(" ", 3); + int version = Integer.parseInt(parts[0]); + int mask = Integer.parseInt(parts[1]); + byte[] data = parts[2].getBytes(StandardCharsets.UTF_8); + int size = version * 4 + 17; + if (mask < 0) { + // segno scores the masks before writing the format bits, this + // encoder after (as the standard's evaluation reads the whole + // symbol), so the automatic choice can differ while both + // symbols are valid. The fixed-mask matrices are the check. + for (int y = 0; y < size; y++) { + reader.readLine(); + } + assertTrue(isOneOfTheFixedMasks(data, version), "version " + version); + checked++; + continue; + } + QrCode code = QrCode.encode(data, version, mask); + assertEquals(size, code.size()); + StringBuilder theirs = new StringBuilder(); + StringBuilder ours = new StringBuilder(); + for (int y = 0; y < size; y++) { + theirs.append(reader.readLine()).append('\n'); + for (int x = 0; x < size; x++) { + ours.append(code.isDark(x, y) ? '1' : '0'); + } + ours.append('\n'); + } + if (!theirs.toString().equals(ours.toString())) { + failures.append(version).append('/').append(mask).append(' '); + } + checked++; + } + assertEquals(90, checked); + assertEquals("", failures.toString(), "version/mask that differ"); + } + + private static boolean isOneOfTheFixedMasks(byte[] data, int version) { + QrCode auto = QrCode.encode(data, version, -1); + for (int mask = 0; mask < 8; mask++) { + if (same(auto, QrCode.encode(data, version, mask))) { + return true; + } + } + return false; + } + + private static boolean same(QrCode a, QrCode b) { + for (int y = 0; y < a.size(); y++) { + for (int x = 0; x < a.size(); x++) { + if (a.isDark(x, y) != b.isDark(x, y)) { + return false; + } + } + } + return true; + } + + @Test + void picksTheSmallestVersionThatFits() { + assertEquals(21, QrCode.encode(new byte[14]).size()); + assertEquals(25, QrCode.encode(new byte[15]).size()); + assertEquals(57, QrCode.encode(new byte[213]).size()); + } +} diff --git a/scripts/bastion/common/src/test/resources/qr-vectors.txt b/scripts/bastion/common/src/test/resources/qr-vectors.txt new file mode 100644 index 00000000000..9ca0de6a320 --- /dev/null +++ b/scripts/bastion/common/src/test/resources/qr-vectors.txt @@ -0,0 +1,3600 @@ +1 -1 otpauth://totp +111111101001101111111 +100000100010101000001 +101110100001101011101 +101110101100001011101 +101110101000101011101 +100000101110101000001 +111111101010101111111 +000000001111000000000 +100010111100111111001 +110010011001010111100 +101000110010001111010 +111101001011000110011 +100101101011110011000 +000000001001000010110 +111111101100011011010 +100000100010100110001 +101110101001010100000 +101110100100000110111 +101110100010011010000 +100000100101010010000 +111111101101100101001 +1 0 otpauth://totp +111111100011101111111 +100000101111001000001 +101110100100001011101 +101110100110001011101 +101110101010101011101 +100000100011001000001 +111111101010101111111 +000000000101000000000 +101010100110100010010 +011111010100111010001 +000101111111100010111 +101111011001010100001 +110111111001100001010 +000000001100101111011 +111111100001110110111 +100000100000110100011 +101110101011000110010 +101110100001101011010 +101110101111110111101 +100000100111000000010 +111111101111110111011 +1 1 otpauth://totp +111111101110101111111 +100000100010001000001 +101110101001001011101 +101110100011001011101 +101110100111101011101 +100000101110001000001 +111111101010101111111 +000000000000000000000 +101000110011100100101 +001010000001101111011 +010000101010110111101 +111010001100000001011 +100010101100110100000 +000000001001111010001 +111111101100100011101 +100000100101100001001 +101110100110010011000 +101110100100111110000 +101110101010100010111 +100000100010010101000 +111111101010100010001 +1 2 otpauth://totp +111111100101101111111 +100000100110101000001 +101110101010001011101 +101110101111101011101 +101110101100101011101 +100000101010101000001 +111111101010101111111 +000000001100100000000 +101111100000101111100 +101110000101001011111 +001011110001101100110 +011110001000100101111 +111001110111101111011 +000000001101011110101 +111111100111111000110 +100000101001000101101 +101110101101001000011 +101110101000011010100 +101110101001111001100 +100000100110110001100 +111111101001111001010 +1 3 otpauth://totp +111111101101101111111 +100000101011001000001 +101110100100101011101 +101110101111101011101 +101110100001001011101 +100000100100001000001 +111111101010101111111 +000000001001000000000 +101101110110001001011 +101110000101001011111 +100110111100000001011 +101000011110010011001 +111001110111101111011 +000000001000110011000 +111111101001001110000 +100000101001000101101 +101110100000100101110 +101110101110101100010 +101110101001111001100 +100000100011011100001 +111111101111001111100 +1 4 otpauth://totp +111111101001101111111 +100000100010101000001 +101110100001101011101 +101110101100001011101 +101110101000101011101 +100000101110101000001 +111111101010101111111 +000000001111000000000 +100010111100111111001 +110010011001010111100 +101000110010001111010 +111101001011000110011 +100101101011110011000 +000000001001000010110 +111111101100011011010 +100000100010100110001 +101110101001010100000 +101110100100000110111 +101110100010011010000 +100000100101010010000 +111111101101100101001 +1 5 otpauth://totp +111111100110101111111 +100000101010101000001 +101110101010001011101 +101110101001101011101 +101110100100101011101 +100000100110101000001 +111111101010101111111 +000000001000100000000 +100000101000111001110 +100000001011000101110 +001011110001101100110 +011010001100100001111 +100010101100110100000 +000000001001011010101 +111111100111111000110 +100000100111001011100 +101110100101001000011 +101110100100011110100 +101110100010100010111 +100000100010110101100 +111111101001111001010 +1 6 otpauth://totp +111111101110101111111 +100000101010101000001 +101110101000001011101 +101110100001101011101 +101110101101101011101 +100000100101101000001 +111111101010101111111 +000000000000100000000 +100111111010110010111 +100000001011000101110 +000010111000100101111 +011001001111100010111 +100010101100110100000 +000000001001000010110 +111111101101101010100 +100000101111001011100 +101110101100000001010 +101110101111011101100 +101110100010100010111 +100000100010101101111 +111111101011101011000 +1 7 otpauth://totp +111111100011101111111 +100000100101001000001 +101110100101001011101 +101110100110001011101 +101110100000101011101 +100000101010001000001 +111111101010101111111 +000000000111000000000 +100101101111110100000 +011111010100111010001 +010111101101110000101 +100110010000011101000 +110111111001100001010 +000000001110111101001 +111111100000111111110 +100000101000110100011 +101110100001010100000 +101110101000100010011 +101110100111110111101 +100000100101010010000 +111111101110111110010 +2 -1 otpauth://totp/Bastion:ana +1111111001110001001111111 +1000001010000001101000001 +1011101001100000101011101 +1011101001000011001011101 +1011101010101001001011101 +1000001001111111001000001 +1111111010101010101111111 +0000000001011100000000000 +1010101000100100100010010 +1110010100000001111101001 +0011001000011010010100111 +0111100100011110101010010 +0010011011011101111000011 +0100010010110000001101001 +1000111010100100111001111 +0111100001010111010110001 +1001011001001100111111010 +0000000011101000100010011 +1111111001101101101011111 +1000001000101100100010011 +1011101011111110111111000 +1011101001101011000010000 +1011101010000111100010101 +1000001001110100101100010 +1111111011001101110000011 +2 0 otpauth://totp/Bastion:ana +1111111001110001001111111 +1000001010000001101000001 +1011101001100000101011101 +1011101001000011001011101 +1011101010101001001011101 +1000001001111111001000001 +1111111010101010101111111 +0000000001011100000000000 +1010101000100100100010010 +1110010100000001111101001 +0011001000011010010100111 +0111100100011110101010010 +0010011011011101111000011 +0100010010110000001101001 +1000111010100100111001111 +0111100001010111010110001 +1001011001001100111111010 +0000000011101000100010011 +1111111001101101101011111 +1000001000101100100010011 +1011101011111110111111000 +1011101001101011000010000 +1011101010000111100010101 +1000001001110100101100010 +1111111011001101110000011 +2 1 otpauth://totp/Bastion:ana +1111111010100100001111111 +1000001001010100101000001 +1011101010110101101011101 +1011101000010110001011101 +1011101001111100001011101 +1000001010101010001000001 +1111111010101010101111111 +0000000000001001000000000 +1010001101110001100100101 +1011000001010100101000011 +0110011101001111000001101 +0010110001001011111111000 +0111001110001000101101001 +0001000111100101011000011 +1101101111110001101100101 +0010110100000010000011011 +1100001100011001111110000 +0000000010111101100011001 +1111111010111000101010101 +1000001001111001100011001 +1011101000101011111110010 +1011101000111110010111010 +1011101011010010110111111 +1000001000100001111001000 +1111111010011000100101001 +2 2 otpauth://totp/Bastion:ana +1111111000010010101111111 +1000001000011101101000001 +1011101010000011001011101 +1011101011011111001011101 +1011101011001010101011101 +1000001011100011001000001 +1111111010101010101111111 +0000000011000000000000000 +1011111001000111001111100 +0010000000011101100001010 +0000101011111001110111011 +1011110000000010110110001 +0001111000111110011011111 +1000000110101100010001010 +1011011001000111011010011 +1011110101001011001010010 +1010111010101111111110110 +0000000011110100100010000 +1111111000001110101010011 +1000001010110000100010000 +1011101010011101111110100 +1011101011110111011110011 +1011101011100100000001001 +1000001001101000110000001 +1111111010101110010011111 +2 3 otpauth://totp/Bastion:ana +1111111010010010101111111 +1000001011000110101000001 +1011101001101110101011101 +1011101011011111001011101 +1011101000010001101011101 +1000001000001110101000001 +1111111010101010101111111 +0000000010011011000000000 +1011011100101010101001011 +0010000000011101100001010 +1011111000100010101100000 +0110010101101111011011100 +0001111000111110011011111 +0011010101110111001010001 +0110111100101010110111110 +1011110101001011001010010 +0001101001110100111111101 +0000000010011001100011101 +1111111010001110101010011 +1000001011101011100011011 +1011101001110000111111001 +1011101011110111011110011 +1011101010111111011010010 +1000001000000101011101100 +1111111010101110010011111 +2 4 otpauth://totp/Bastion:ana +1111111011010101101111111 +1000001001011010101000001 +1011101000111011101011101 +1011101011100111101011101 +1011101010001101101011101 +1000001010100100001000001 +1111111010101010101111111 +0000000011111000100000000 +1000101110000000011111001 +0101000111011010100110010 +1000011011000001001111100 +0011000000111010001110110 +0110111111111001011100111 +1111000001101011010110010 +0011101001111111100010100 +0011000101110011110010101 +1101111101101000111111110 +0000000010110011100011000 +1111111010110110101010100 +1000001000001000100010111 +1011101011011010111111100 +1011101000110000011001011 +1011101001011100111001110 +1000001001010000001000110 +1111111011101001010100111 +2 5 otpauth://totp/Bastion:ana +1111111000100100001111111 +1000001011011100101000001 +1011101010000011001011101 +1011101010111100101011101 +1011101001001010101011101 +1000001000100010001000001 +1111111010101010101111111 +0000000010000001000000000 +1000001011000111011001110 +0001100011111110000010110 +0000101011111001110111011 +1010110001000011110111001 +0111001110001000101101001 +1001000111101101010000010 +1011011001000111011010011 +1000010110101000101001110 +1010111010101111111110110 +0000000010110101100011000 +1111111000111000101010101 +1000001001110001100011000 +1011101000011101111110100 +1011101000010100111101111 +1011101001100100000001001 +1000001000101001110001001 +1111111010011000100101001 +2 6 otpauth://totp/Bastion:ana +1111111010100100001111111 +1000001011011010101000001 +1011101010100111101011101 +1011101000111100101011101 +1011101011011000101011101 +1000001000010010101000001 +1111111010101010101111111 +0000000000000111000000000 +1001111111100011110010111 +0001100011111110000010110 +0010111001101011100101001 +1010000001110011000111111 +0111001110001000101101001 +1111000001101011010110010 +1111111101100011111110111 +1000010110101000101001110 +1000101000111101111110100 +0000000010000101100011110 +1111111010111000101010101 +1000001011110111100011000 +1011101010111001111110000 +1011101010010100111101111 +1011101001110110010011011 +1000001000011001000001111 +1111111010011000100101001 +2 7 otpauth://totp/Bastion:ana +1111111001110001001111111 +1000001000100101001000001 +1011101001110010101011101 +1011101001000011001011101 +1011101000001101101011101 +1000001011101101001000001 +1111111010101010101111111 +0000000001111000100000000 +1001011010110110110100000 +1110010100000001111101001 +0111101100111110110000011 +0101110110001100111000000 +0010011011011101111000011 +0000110110010100101001101 +1010101000110110101011101 +0111100001010111010110001 +1101111101101000111111110 +0000000011111010100010001 +1111111001101101101011111 +1000001010001000100010111 +1011101001101100111111010 +1011101011101011000010000 +1011101000100011000110001 +1000001001100110111110000 +1111111011001101110000011 +3 -1 otpauth://totp/Bastion:ana%40example.com?s +11111110001011000101001111111 +10000010010001111110001000001 +10111010110000011011101011101 +10111010111001110101001011101 +10111010110100001011101011101 +10000010111111010010101000001 +11111110101010101010101111111 +00000000100000111001100000000 +10111110010101100101001111100 +11011101001011001111101110011 +00000110100011110100100101000 +11110001011100110010010101010 +01001110011111111101000001110 +11001101111011001011101111001 +01001111111000111010010011000 +11100101000100111000110111010 +11100111101101000110110101100 +10011001111011101011111111001 +10000111110011111000101100000 +10110100111110110001001101001 +10110010110011110110111110101 +00000000101000101001100010111 +11111110010001010001101010000 +10000010100110110011100010011 +10111010111010001110111111111 +10111010100110001001000000111 +10111010111010011010010010110 +10000010001111111010110100010 +11111110110100110101011100100 +3 0 otpauth://totp/Bastion:ana%40example.com?s +11111110010011111101101111111 +10000010110110111001001000001 +10111010001000100011001011101 +10111010011110110010001011101 +10111010101100110011001011101 +10000010011000010101101000001 +11111110101010101010101111111 +00000000000111111110100000000 +10101010001101011101100010010 +00011000001100001000101001011 +00111110011011001100011101111 +00110100011011110101010010010 +01110110100111000101111001001 +00001000111100001100101000001 +01110111000000000010101011111 +00100000000011111111110000010 +11011111010101111110001101011 +01011100111100101100111000001 +10111111001011000000010100111 +01110001111001110110001010001 +10001010001011001110111110010 +00000000101111101110100011111 +11111110001001101001101010111 +10000010000001110100100011011 +10111010100010110110111111000 +10111010000001001110000111111 +10111010100010100010101010001 +10000010001000111101110011010 +11111110101100001101100100011 +3 1 otpauth://totp/Bastion:ana%40example.com?s +11111110100110101000101111111 +10000010000011101100001000001 +10111010111101110110001011101 +10111010001011100111001011101 +10111010011001100110001011101 +10000010101101000000101000001 +11111110101010101010101111111 +00000000010010101011100000000 +10100011011000001000100100101 +01001101011001011101111100001 +01101011001110011001001000101 +01100001001110100000000111000 +00100011110010010000101100011 +01011101101001011001111101011 +00100010010101010111111110101 +01110101010110101010100101000 +10001010000000101011011000001 +00001001101001111001101101011 +11101010011110010101000001101 +00100100101100100011011111011 +11011111011110011011111111000 +00000000111010111011100010101 +11111110111100111100101011101 +10000010010100100001100010001 +10111010010111100011111110010 +10111010010100011011010010101 +10111010110111110111111111011 +10000010011101101000100110000 +11111110111001011000110001001 +3 2 otpauth://totp/Bastion:ana%40example.com?s +11111110001011000101001111111 +10000010010001111110001000001 +10111010110000011011101011101 +10111010111001110101001011101 +10111010110100001011101011101 +10000010111111010010101000001 +11111110101010101010101111111 +00000000100000111001100000000 +10111110010101100101001111100 +11011101001011001111101110011 +00000110100011110100100101000 +11110001011100110010010101010 +01001110011111111101000001110 +11001101111011001011101111001 +01001111111000111010010011000 +11100101000100111000110111010 +11100111101101000110110101100 +10011001111011101011111111001 +10000111110011111000101100000 +10110100111110110001001101001 +10110010110011110110111110101 +00000000101000101001100010111 +11111110010001010001101010000 +10000010100110110011100010011 +10111010111010001110111111111 +10111010100110001001000000111 +10111010111010011010010010110 +10000010001111111010110100010 +11111110110100110101011100100 +3 3 otpauth://totp/Bastion:ana%40example.com?s +11111110101011000101001111111 +10000010100111001000101000001 +10111010001011000000101011101 +10111010111001110101001011101 +10111010000010111101001011101 +10000010000100001001101000001 +11111110101010101010101111111 +00000000110110001111000000000 +10110111001110111110001001011 +11011101001011001111101110011 +10110010010101000010010011110 +00101000000111101001001110001 +01001110011111111101000001110 +01111001001101111101011001111 +10010110100011100001001000011 +11100101000100111000110111010 +01010011011011110000000011010 +01000000100000110000100100010 +10000111110011111000101100000 +00000000001000000111111011111 +01101011101000101101111111110 +00000000101000101001100010111 +11111110100111100111101010110 +10000010111101101000100011000 +10111010011010001110111111111 +10111010110000111111110110001 +10111010100001000001001001101 +10000010001111111010110100010 +11111110100010000011101010010 +3 4 otpauth://totp/Bastion:ana%40example.com?s +11111110111010110100101111111 +10000010000000001111101000001 +10111010011110010101101011101 +10111010110111111011001011101 +10111010100101111010001011101 +10000010101110100011001000001 +11111110101010101010101111111 +00000000101110110111100000000 +10001011100100010100111111001 +10101100111010111110011111101 +10001010101101111010101011001 +01111101010010111100011011011 +00111111101110001100110000000 +10111100001010111010011110111 +11000011110110110100011101001 +01101001001010110110111001011 +10010110011100110111000100010 +11101000001010011010001110111 +00001011111101110110100010001 +00111000110000111111000011000 +11000011000010000111111111011 +00000000111001011000100011001 +11111110111111011111101010001 +10000010001000111101100010010 +10111010101011111111111110001 +10111010010111111000110001001 +10111010010100010100011100111 +10000010000001110100111010011 +11111110100101000100101101010 +3 5 otpauth://totp/Bastion:ana%40example.com?s +11111110000110101000101111111 +10000010100001101110001000001 +10111010110000011011101011101 +10111010100001001101101011101 +10111010010100001011101011101 +10000010001111000010101000001 +11111110101010101010101111111 +00000000110000101001100000000 +10000010110101100101011001110 +11100101110011110111010110100 +00000110100011110100100101000 +11100001001100100010000101000 +00100011110010010000101100011 +11011101101011011011111111011 +01001111111000111010010011000 +11011101111100000000001111101 +11100111101101000110110101100 +10001001101011111011101111011 +11101010011110010101000001101 +10100100101110100001011101011 +10110010110011110110111110101 +00000000110000010001100010000 +11111110010001010001101010000 +10000010010110100011100010001 +10111010010111100011111110010 +10111010010110011001010000101 +10111010011010011010010010110 +10000010010111000010001100101 +11111110110100110101011100100 +3 6 otpauth://totp/Bastion:ana%40example.com?s +11111110100110101000101111111 +10000010100000001111101000001 +10111010111001010010101011101 +10111010000001001101101011101 +10111010110000101111001011101 +10000010000011001110101000001 +11111110101010101010101111111 +00000000010001001000000000000 +10011111111100101100010010111 +11100101110011110111010110100 +00100010000111010000000001100 +11101101000000101110001001001 +00100011110010010000101100011 +10111100001010111010011110111 +00000110110001110011011010001 +11011101111100000000001111101 +11000011001001100010010001000 +10000101100111110111100011010 +11101010011110010101000001101 +11000101001111000000111100111 +11111011111010111111111111100 +00000000110000010001100010000 +11111110110101110101101010100 +10000010111010101111100010000 +10111010110111100011111110010 +10111010110111111000110001001 +10111010010011010011011011111 +10000010010111000010001100101 +11111110110000010001111000000 +3 7 otpauth://totp/Bastion:ana%40example.com?s +11111110010011111101101111111 +10000010011111110000001000001 +10111010001100000111101011101 +10111010011110110010001011101 +10111010000101111010001011101 +10000010111100110001001000001 +11111110101010101010101111111 +00000000001110110111100000000 +10010110101001111001010100000 +00011000001100001000101001011 +01110111010010000101010100110 +00010000111111010001110110110 +01110110100111000101111001001 +01000001110101000101100001000 +01010011100100100110001111011 +00100000000011111111110000010 +10010110011100110111000100010 +01111000011000001000011100101 +10111111001011000000010100111 +00111000110000111111000011000 +10101110101111101010111110110 +00000000101111101110100011111 +11111110000000100000101011110 +10000010100101010000100011111 +10111010000010110110111111000 +10111010101000000111001110110 +10111010000110000110001110101 +10000010001000111101110011010 +11111110100101000100101101010 +4 -1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3P +111111101100011000100100001111111 +100000101011001000110100101000001 +101110101000111110001110001011101 +101110100110100001110100001011101 +101110101010000000000100001011101 +100000100100101000001011001000001 +111111101010101010101010101111111 +000000000111110100100001100000000 +100111111110101001110010010010111 +100011001000100110010011010011110 +011010100001001100100111010101111 +001110001010100001000000011000110 +001000110100111111000000101100000 +110111011011101011110111010100100 +000111101101110111111000011000100 +000001000000010010111000000011110 +001101100011000110001111101111010 +110011001011011111111010001111101 +100110101101101101011101100111101 +011001010100101010110000001101101 +101001110000101100011010110100010 +110000010101000000000001010111000 +100100110100110101101001010111111 +101011010100011011010010111100101 +111111100010011010010001111111000 +000000001011101100100101100010110 +111111101010100100111101101011100 +100000101111101010111001100011101 +101110101000110101001111111110000 +101110101100100000101010000100111 +101110100111101100011110010010111 +100000100001000010101011000100111 +111111101110000111001001101010000 +4 0 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3P +111111100001001101110001001111111 +100000101110100101011001001000001 +101110100100100010010010001011101 +101110100001011110001011101011101 +101110101101000111000011001011101 +100000100010011110111101101000001 +111111101010101010101010101111111 +000000000010011001001100000000000 +101010100010110101101110000010010 +011100010111011001101100101100001 +011101100110001011100000010010111 +111000011100010111110110101110000 +011101100001101010010101111001010 +011010010110000110011010111001001 +011011110001101011100100000100111 +111110011111101101000111111100001 +001010100100000001001000101000010 +000101011101101001001100111001011 +110011111000111000001000110010111 +110100011001000111011101100000000 +110101101100110000000110101000001 +001111001010111111111110101000111 +100011110011110010101110010000111 +011101000010101101100100001010011 +101010110111001111000100111110010 +000000001110000001001000100011011 +111111100110111000100001101011111 +100000100000010101000110100010010 +101110101111110010001000111111000 +101110100010010110011100110010001 +101110101010111001001011000111101 +100000100100101111000110101001010 +111111101010011011010101110110011 +4 1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3P +111111101100011000100100001111111 +100000100011110000001100001000001 +101110101001110111000111001011101 +101110100100001011011110101011101 +101110100000010010010110001011101 +100000101111001011101000101000001 +111111101010101010101010101111111 +000000000111001100011001000000000 +101000110111100000111011000100101 +001001000010001100111001111001011 +001000110011011110110101000111101 +101101001001000010100011111011010 +001000110100111111000000101100000 +001111000011010011001111101100011 +001110100100111110110001010001101 +101011001010111000010010101001011 +011111110001010100011101111101000 +010000001000111100011001101100001 +100110101101101101011101100111101 +100001001100010010001000110101010 +100000111001100101010011111101011 +011010011111101010101011111101101 +110110100110100111111011000101101 +001000010111111000110001011111001 +111111100010011010010001111111000 +000000001011010100011101100010001 +111111101011101101110100101010101 +100000100101000000010011100011000 +101110100010100111011101111110010 +101110100111000011001001100111011 +101110101111101100011110010010111 +100000100001111010010011111100000 +111111101111001110000000100011001 +4 2 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3P +111111100111000011111111001111111 +100000100111010100101000101000001 +101110101010101100011100001011101 +101110101000101111111010001011101 +101110101011001001001101001011101 +100000101011101111001100001000001 +111111101010101010101010101111111 +000000001011101000111101100000000 +101111100100111011100000001111100 +101101000110101000011101011101111 +010011101000000101101110011100110 +001001001101100110000111011111110 +010011101111100100011011110111011 +101011000111110111101011001000111 +010101111111100101101010001010110 +001111001110011100110110001101111 +000100101010001111000110100110011 +110100001100011000111101001000101 +111101110110110110000110111100110 +000101001000110110101100010001110 +111011100010111110001000100110000 +111110011011001110001111011001001 +101101111101111100100000011110110 +101100010011011100010101111011101 +100100111001000001001010111110011 +000000001111110000111001100010101 +111111100000110110101111101011110 +100000101001100100110111100011100 +101110101001111100000110111111001 +101110101011100111101101000011111 +101110101100110111000101001001100 +100000100101011110110111011000100 +111111101100010101011011111000010 +4 3 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3P +111111101111000011111111001111111 +100000101010111001000101001000001 +101110100100011010101010101011101 +101110101000101111111010001011101 +101110100110100100100000101011101 +100000100101011001111010101000001 +111111101010101010101010101111111 +000000001110000101010000000000000 +101101110010001101010110101001011 +101101000110101000011101011101111 +111110100101101000000011110001011 +111111011011010000110001101001000 +010011101111100100011011110111011 +000110001010011010000110100101010 +100011101001010011011100111100000 +001111001110011100110110001101111 +101001100111100010101011001011110 +000010011010101110001011111110011 +111101110110110110000110111100110 +101000000101011011000001111100011 +001101110100001000111110010000110 +111110011011001110001111011001001 +000000110000010001001101110011011 +011010000101101010100011001101011 +100100111001000001001010111110011 +000000001010011101010100100011000 +111111101110000000011001101011000 +100000101001100100110111100011100 +101110100100010001101011111110100 +101110101101010001011011110101001 +101110101100110111000101001001100 +100000100000110011011010110101001 +111111101010100011101101001110100 +4 4 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3P +111111101011011111100011001111111 +100000100011001000110100101000001 +101110100001001111111111101011101 +101110101011001100011001101011101 +101110101111010101010001001011101 +100000101111110011010000001000001 +111111101010101010101010101111111 +000000001000001011011110000000000 +100010111000100111111100011111001 +110001011010110100000001000001100 +110000101011100110001101111111010 +101010001110000101100100111100010 +001111110011111000000111101011000 +110111011011101011110111010100100 +110110111100000110001001101001010 +101100001101111111010101101110011 +011000110110010011011010111010000 +101000010000000100100001010100110 +011110110101010101100101011111010 +100110001011010101001111110010010 +100111111110100010010100111010011 +100010000111010010010011000101010 +001110111110011111000011111101010 +001111010000111111110110011000001 +111000100101011101010110111110000 +000000001011101100100101100010110 +111111101011010101001100101010010 +100000100010000111010100100010000 +101110101101100000011010111111010 +101110100111111011110001011111100 +101110100111010100100110101010000 +100000100110111101010100111011000 +111111101000001001000111100100001 +4 5 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3P +111111100100011000100100001111111 +100000101011010000101100101000001 +101110101010101100011100001011101 +101110101110100001110100001011101 +101110100011001001001101001011101 +100000100111101011001000001000001 +111111101010101010101010101111111 +000000001111101100111001100000000 +100000101100111011100000011001110 +100011001000100110010011010011110 +010011101000000101101110011100110 +001101001001100010000011011011110 +001000110100111111000000101100000 +101111000011110011101111001100111 +010101111111100101101010001010110 +000001000000010010111000000011110 +000100101010001111000110100110011 +110000001000011100111001001100101 +100110101101101101011101100111101 +000001001100110010101000010101110 +111011100010111110001000100110000 +110000010101000000000001010111000 +101101111101111100100000011110110 +101000010111011000010001111111101 +111111100010011010010001111111000 +000000001011110100111101100010101 +111111100000110110101111101011110 +100000100111101010111001100011101 +101110100001111100000110111111001 +101110100111100011101001000111111 +101110100111101100011110010010111 +100000100001011010110011011100100 +111111101100010101011011111000010 +4 6 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3P +111111101100011000100100001111111 +100000101011001000110100101000001 +101110101000111110001110001011101 +101110100110100001110100001011101 +101110101010000000000100001011101 +100000100100101000001011001000001 +111111101010101010101010101111111 +000000000111110100100001100000000 +100111111110101001110010010010111 +100011001000100110010011010011110 +011010100001001100100111010101111 +001110001010100001000000011000110 +001000110100111111000000101100000 +110111011011101011110111010100100 +000111101101110111111000011000100 +000001000000010010111000000011110 +001101100011000110001111101111010 +110011001011011111111010001111101 +100110101101101101011101100111101 +011001010100101010110000001101101 +101001110000101100011010110100010 +110000010101000000000001010111000 +100100110100110101101001010111111 +101011010100011011010010111100101 +111111100010011010010001111111000 +000000001011101100100101100010110 +111111101010100100111101101011100 +100000101111101010111001100011101 +101110101000110101001111111110000 +101110101100100000101010000100111 +101110100111101100011110010010111 +100000100001000010101011000100111 +111111101110000111001001101010000 +4 7 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3P +111111100001001101110001001111111 +100000100100110111001011001000001 +101110100101101011011011001011101 +101110100001011110001011101011101 +101110100111010101010001001011101 +100000101011010111110100101000001 +111111101010101010101010101111111 +000000000000001011011110000000000 +100101101011111100100111010100000 +011100010111011001101100101100001 +001111110100011001110010000000101 +110001010101011110111111100111001 +011101100001101010010101111001010 +001000000100010100001000101011011 +010010111000100010101101001101110 +111110011111101101000111111100001 +011000110110010011011010111010000 +001100010100100000000101110000010 +110011111000111000001000110010111 +100110001011010101001111110010010 +111100100101111001001111100001000 +001111001010111111111110101000111 +110001100001100000111100000010101 +010100001011100100101101000011010 +101010110111001111000100111110010 +000000001100010011011010100011001 +111111100111110001101000101010110 +100000101000010101000110100010010 +101110100101100000011010111111010 +101110101011011111010101111011000 +101110100010111001001011000111101 +100000100110111101010100111011000 +111111101011010010011100111111010 +5 -1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&iss +1111111000001000010010010101001111111 +1000001010110001101011100100001000001 +1011101010110101001101011001001011101 +1011101010000100110101001101101011101 +1011101000001011101111010001101011101 +1000001001110101111011100000101000001 +1111111010101010101010101010101111111 +0000000011011111100010101001100000000 +1000001010110010000101100010011001110 +0001010110110110110000000010011110110 +0011101101100111100001001001110111111 +1010010101011011001000111010110001001 +1001001001011111000110111001111101001 +1100000100000001101111010111000000010 +0110001111111111101011001101011011011 +0000110011111011001010011000110001110 +1110001010000110100010011101011101101 +1001010110011011100010100010111101010 +0010011111111101010111011011100100101 +1001100101100111000000111010010001010 +0001011010111001111100010111111110110 +0110010100111111111100011001010010100 +1110101101110011101000001101000000011 +0111010000011110101000110010111011011 +0110111011110100010011001101011110001 +1001010100010101110010000010110101000 +1011111000010101110011101011011011011 +1001010001111010101000101010001000101 +1001111011110000010010001111111110111 +0000000011100101101110110001100011000 +1111111001001001011101010110101010101 +1000001000001111101010010001100011010 +1011101000100100011001110011111110101 +1011101000010101001000000101011001011 +1011101001010001011001001000100100011 +1000001001110011100000001010001001001 +1111111010011101100110001000100011101 +5 0 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&iss +1111111001011101000111000000001111111 +1000001011101100110110111001001000001 +1011101001010110101110111010101011101 +1011101001111011001010110010001011101 +1011101011101000001100110010001011101 +1000001000101000100110111101101000001 +1111111010101010101010101010101111111 +0000000000000010111111110100100000000 +1010101001010001100110000001100010010 +1110100001001001001111111101100001001 +0000001110000100000010101010010100011 +0111000000000110010101100111101100010 +1100011100001010010011101100101000011 +0001010001011100110010001010011101001 +0101101100011100001000101110111000111 +1111000100000100110101100111001110001 +1101101001100101000001111110111110001 +0100000011000110111111111111100000001 +0111001010101000000010001110110001111 +0100110000111010011101100111001100001 +0010111001011010011111110100011101010 +1001100011000000000011100110101101011 +1101001110010000001011101110100011111 +1010000101000011110101101111100110000 +0011101110100001000110011000001011011 +0100000001001000101111011111101000011 +1000011011110110010000001000111000111 +0110100110000101010111010101110111010 +1010011000010011110001101100111111011 +0000000010111000110011101100100010011 +1111111000011100001000000011101011111 +1000001001010010110111001100100010001 +1011101011000111111010010000111111001 +1011101001101010110111111010100110100 +1011101010110010111010101011000111111 +1000001000101110111101010111010100010 +1111111011001000110011011101110110111 +5 1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&iss +1111111010001000010010010101001111111 +1000001000111001100011101100001000001 +1011101010000011111011101111101011101 +1011101000101110011111100111001011101 +1011101000111101011001100111001011101 +1000001011111101110011101000101000001 +1111111010101010101010101010101111111 +0000000001010111101010100001100000000 +1010001100000100110011010100100100101 +1011110100011100011010101000110100011 +0101011011010001010111111111000001001 +0010010101010011000000110010111001000 +1001001001011111000110111001111101001 +0100000100001001100111011111001000011 +0000111001001001011101111011101101101 +1010010001010001100000110010011011011 +1000111100110000010100101011101011011 +0001010110010011101010101010110101011 +0010011111111101010111011011100100101 +0001100101101111001000110010011001011 +0111101100001111001010100001001000000 +1100110110010101010110110011111000001 +1000011011000101011110111011110110101 +1111010000010110100000111010110011010 +0110111011110100010011001101011110001 +0001010100011101111010001010111101001 +1101001110100011000101011101101101101 +0011110011010000000010000000100010000 +1111001101000110100100111001111110001 +0000000011101101100110111001100011001 +1111111011001001011101010110101010101 +1000001000000111100010011001100011011 +1011101000010010101111000101111110011 +1011101000111111100010101111110011110 +1011101011100111101111111110010010101 +1000001001111011101000000010000001000 +1111111010011101100110001000100011101 +5 2 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&iss +1111111000111110100100100011101111111 +1000001001110000101010100101001000001 +1011101010110101001101011001001011101 +1011101011100111010110101110001011101 +1011101010001011101111010001101011101 +1000001010110100111010100001101000001 +1111111010101010101010101010101111111 +0000000010011110100011101000100000000 +1011111000110010000101100010001111100 +0010110101010101010011100001111101010 +0011101101100111100001001001110111111 +1011010100011010001001111011110000001 +1111111111101001110000001111001011111 +1101000101000000101110010110000001010 +0110001111111111101011001101011011011 +0011010000011000101001111011010010010 +1110001010000110100010011101011101101 +1000010111011010100011100011111100010 +0100101001001011100001101101010010011 +1000100100100110000001111011010000010 +0001011010111001111100010111111110110 +0101110111011100011111111010110001000 +1110101101110011101000001101000000011 +0110010001011111101001110011111010011 +0000001101000010100101111011101000111 +1000010101010100110011000011110100000 +1011111000010101110011101011011011011 +1010110010011001001011001001101011001 +1001111011110000010010001111111110111 +0000000010100100101111110000100010000 +1111111001111111101011100000101010011 +1000001011001110101011010000100010010 +1011101010100100011001110011111110101 +1011101011110110101011100110111010111 +1011101011010001011001001000100100011 +1000001000110010100001001011001000001 +1111111010101011010000111110010101011 +5 3 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&iss +1111111010111110100100100011101111111 +1000001010101011110001111110001000001 +1011101001011000100000110100101011101 +1011101011100111010110101110001011101 +1011101001010000110100001010101011101 +1000001001011001010111001100001000001 +1111111010101010101010101010101111111 +0000000011000101111000110011100000000 +1011011101011111101000001111101001011 +0010110101010101010011100001111101010 +1000111110111100111010010010101100100 +0110110001110111100100010110011101100 +1111111111101001110000001111001011111 +0110010110011011110101001101011010001 +1011101010010010000110100000110110110 +0011010000011000101001111011010010010 +0101011001011101111001000110000110110 +0101110010110111001110001110010001111 +0100101001001011100001101101010010011 +0011110111111101011010100000001011001 +1100111111010100010001111010010011011 +0101110111011100011111111010110001000 +0101111110101000110011010110011011000 +1011110100110010000100011110010111110 +0000001101000010100101111011101000111 +0011000110001111101000011000101111011 +0110011101111000011110000110110110110 +1010110010011001001011001001101011001 +0010101000101011001001010100111111100 +0000000011001001000010011101100011101 +1111111011111111101011100000101010011 +1000001010010101110000001011100011001 +1011101001001001110100011110111111000 +1011101011110110101011100110111010111 +1011101010001010000010010011111111000 +1000001001011111001100100110100101100 +1111111010101011010000111110010101011 +5 4 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&iss +1111111011111001100011100100101111111 +1000001000110111101101100010001000001 +1011101000001101110101100001101011101 +1011101011011111101110010110101011101 +1011101011001100101000010110101011101 +1000001011110011111101100110101000001 +1111111010101010101010101010101111111 +0000000010100110011011010000000000000 +1000101111110101000010100101011111001 +0101110010010010010100100110111010010 +1011011101011111011001110001001111000 +0011100100100010110001000011001000110 +1000111000101110110111001000001100111 +1010000010000111101001010001000110010 +1110111111000111010011110101100011100 +1011100000100000010001000011101010101 +1001001101000001100101011010011010101 +1111010000011101100100100100111011010 +1100011001110011011001010101101010100 +0000010100011110111001000011101000101 +0110011101111110111011010000111001110 +0010110000011011011000111101110110000 +0110011101001011010000110101111000100 +1110100001100111010001001011000010100 +0111001010000101100010111100101111111 +1111010010010011110100000100110011000 +0011001000101101001011010011100011100 +0010000010100001110011110001010011110 +1110111100110111010101001000111111111 +0000000011100011101000110111100011000 +1111111011000111010011011000101010100 +1000001001110110010011101000100010101 +1011101011100011011110110100111111101 +1011101000110001101100100001111101111 +1011101001101001100001110000011100100 +1000001000001010011001110011110000110 +1111111011101100010111111001010010011 +5 5 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&iss +1111111000001000010010010101001111111 +1000001010110001101011100100001000001 +1011101010110101001101011001001011101 +1011101010000100110101001101101011101 +1011101000001011101111010001101011101 +1000001001110101111011100000101000001 +1111111010101010101010101010101111111 +0000000011011111100010101001100000000 +1000001010110010000101100010011001110 +0001010110110110110000000010011110110 +0011101101100111100001001001110111111 +1010010101011011001000111010110001001 +1001001001011111000110111001111101001 +1100000100000001101111010111000000010 +0110001111111111101011001101011011011 +0000110011111011001010011000110001110 +1110001010000110100010011101011101101 +1001010110011011100010100010111101010 +0010011111111101010111011011100100101 +1001100101100111000000111010010001010 +0001011010111001111100010111111110110 +0110010100111111111100011001010010100 +1110101101110011101000001101000000011 +0111010000011110101000110010111011011 +0110111011110100010011001101011110001 +1001010100010101110010000010110101000 +1011111000010101110011101011011011011 +1001010001111010101000101010001000101 +1001111011110000010010001111111110111 +0000000011100101101110110001100011000 +1111111001001001011101010110101010101 +1000001000001111101010010001100011010 +1011101000100100011001110011111110101 +1011101000010101001000000101011001011 +1011101001010001011001001000100100011 +1000001001110011100000001010001001001 +1111111010011101100110001000100011101 +5 6 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&iss +1111111010001000010010010101001111111 +1000001010110111101101100010001000001 +1011101010010001101001111101101011101 +1011101000000100110101001101101011101 +1011101010011001111101000011101011101 +1000001001000101001011010000001000001 +1111111010101010101010101010101111111 +0000000001011001100100101111100000000 +1001111110010110100001000110110010111 +0001010110110110110000000010011110110 +0001111111110101110011011011100101101 +1010100101101011111000001010000001111 +1001001001011111000110111001111101001 +1010000010000111101001010001000110010 +0010101011011011001111101001111111111 +0000110011111011001010011000110001110 +1100011000010100110000001111001111111 +1001100110101011010010010010001101100 +0010011111111101010111011011100100101 +1111100011100001000110111100010111010 +0101111110011101011000110011011010010 +0110010100111111111100011001010010100 +1100111111100001111010011111010010001 +0111100000101110011000000010001011101 +0110111011110100010011001101011110001 +1111010010010011110100000100110011000 +1111011100110001010111001111111111111 +1001010001111010101000101010001000101 +1011101001100010000000011101111110101 +0000000011010101011110000001100011110 +1111111011001001011101010110101010101 +1000001010001001101100010111100011010 +1011101010000000111101010111111110001 +1011101010010101001000000101011001011 +1011101001000011001011011010110110001 +1000001001000011010000111010111001111 +1111111010011101100110001000100011101 +5 7 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&iss +1111111001011101000111000000001111111 +1000001001001000010010011101101000001 +1011101001000100111100101000101011101 +1011101001111011001010110010001011101 +1011101001001100101000010110101011101 +1000001010111010110100101111101000001 +1111111010101010101010101010101111111 +0000000000100110011011010000000000000 +1001011011000011110100010011110100000 +1110100001001001001111111101100001001 +0100101010100000100110001110110000111 +0101010010010100000111110101111110000 +1100011100001010010011101100101000011 +0101110101111000010110101110111001101 +0111111110001110011010111100101010101 +1111000100000100110101100111001110001 +1001001101000001100101011010011010101 +0110010001010100101101101101110010011 +0111001010101000000010001110110001111 +0000010100011110111001000011101000101 +0000101011001000001101100110001111000 +1001100011000000000011100110101101011 +1001101010110100101111001010000111011 +1000010111010001100111111101110100010 +0011101110100001000110011000001011011 +0000100101101100001011111011001100111 +1010001001100100000010011010101010101 +0110100110000101010111010101110111010 +1110111100110111010101001000111111111 +0000000010101010100001111110100010001 +1111111000011100001000000011101011111 +1000001011110110010011101000100010101 +1011101001010101101000000010111111011 +1011101011101010110111111010100110100 +1011101000010110011110001111100011011 +1000001000111100101111000101000110000 +1111111011001000110011011101110110111 +6 -1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm= +11111110100110011001011100010101001111111 +10000010010001010000111001101100001000001 +10111010101101100110110111001111101011101 +10111010010100110110111001101110001011101 +10111010001101101111011001101010001011101 +10000010101000010000001000001110001000001 +11111110101010101010101010101010101111111 +00000000001110001011000100001010100000000 +10100011011010101001100100100010100100101 +11110100111011101001100111011001010101101 +00010111110010010011110100011101101110001 +01010001010111000011001100101011010011001 +01011011101100111100100110011010101100001 +10001001111001000011101110111100101101000 +01000010101000011111110110010111001111101 +10100000000101010000101010100000110010011 +10101011000100100101010001000100001000011 +01000100000100010111010011101010111101011 +11011011111111011101000110011111011011111 +10100000011010000011001010010011010001010 +01000010010100110010100100111001101000101 +00100101100111001110111111011111101100101 +00110010000100010001111100010001111000101 +01011100000101010001001100111010000001001 +00010011110110111000000101000001001110010 +11111100001111101001000110011111000101001 +01101011001000101001110100110001101011111 +11101000011011010011100000111000010101000 +00001111110110001100001110001000001000011 +00110000000111001111111111011001011100011 +11111011010001001111011101111111101011111 +00111001100011001000000000101001000101010 +11000110111101000100110110111101111111010 +00000000111100010110101010101001100010001 +11111110101111101101101100011010101011111 +10000010010001010010000100010010100011010 +10111010010111110111100110001001111111111 +10111010000110100000110110011011110010001 +10111010111001100111000100110101010010001 +10000010011110111010101010010001111001000 +11111110100100110110100110011000101110001 +6 0 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm= +11111110010011001100001001000000001111111 +10000010100100000101101100111001001000001 +10111010011000110011100010011010101011101 +10111010000001100011101100111011001011101 +10111010111000111010001100111111001011101 +10000010011101000101011101011011001000001 +11111110101010101010101010101010101111111 +00000000011011011110010001011111100000000 +10101010001111111100110001110111100010010 +10100001101110111100110010001100000000111 +01000010100111000110100001001000111011011 +00000100000010010110011001111110000110011 +00001110111001101001110011001111111001011 +11011100101100010110111011101001111000010 +00010111111101001010100011000010011010111 +11110101010000000101111111110101100111001 +11111110010001110000000100010001011101001 +00010001010001000010000110111111101000001 +10001110101010001000010011001010001110101 +11110101001111010110011111000110000100000 +00010111000001100111110001101100111101111 +01110000110010011011101010001010111001111 +01100111010001000100101001000100101101111 +00001001010000000100011001101111010100011 +01000110100011101101010000010100011011000 +10101001011010111100010011001010010000011 +00111110011101111100100001100100111110101 +10111101001110000110110101101101000000010 +01011010100011011001011011011101011101001 +01100101010010011010101010001100001001001 +10101110000100011010001000101010111110101 +01101100110110011101010101111100010000000 +10010011101000010001100011101000111110000 +00000000101001000011111111111100100011011 +11111110011010111000111001001111101010101 +10000010000100000111010001000111100010000 +10111010100010100010110011011100111110101 +10111010010011110101100011001110100111011 +10111010101100110010010001100000000111011 +10000010001011101111111111000100101100010 +11111110110001100011110011001101111011011 +6 1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm= +11111110100110011001011100010101001111111 +10000010010001010000111001101100001000001 +10111010101101100110110111001111101011101 +10111010010100110110111001101110001011101 +10111010001101101111011001101010001011101 +10000010101000010000001000001110001000001 +11111110101010101010101010101010101111111 +00000000001110001011000100001010100000000 +10100011011010101001100100100010100100101 +11110100111011101001100111011001010101101 +00010111110010010011110100011101101110001 +01010001010111000011001100101011010011001 +01011011101100111100100110011010101100001 +10001001111001000011101110111100101101000 +01000010101000011111110110010111001111101 +10100000000101010000101010100000110010011 +10101011000100100101010001000100001000011 +01000100000100010111010011101010111101011 +11011011111111011101000110011111011011111 +10100000011010000011001010010011010001010 +01000010010100110010100100111001101000101 +00100101100111001110111111011111101100101 +00110010000100010001111100010001111000101 +01011100000101010001001100111010000001001 +00010011110110111000000101000001001110010 +11111100001111101001000110011111000101001 +01101011001000101001110100110001101011111 +11101000011011010011100000111000010101000 +00001111110110001100001110001000001000011 +00110000000111001111111111011001011100011 +11111011010001001111011101111111101011111 +00111001100011001000000000101001000101010 +11000110111101000100110110111101111111010 +00000000111100010110101010101001100010001 +11111110101111101101101100011010101011111 +10000010010001010010000100010010100011010 +10111010010111110111100110001001111111111 +10111010000110100000110110011011110010001 +10111010111001100111000100110101010010001 +10000010011110111010101010010001111001000 +11111110100100110110100110011000101110001 +6 2 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm= +11111110001011110100110001111000101111111 +10000010000011000010101011111110001000001 +10111010100000001011011010100010001011101 +10111010100110100100101011111100001011101 +10111010100000000010110100000111101011101 +10000010111010000010011010011100001000001 +11111110101010101010101010101010101111111 +00000000111100011001010110011000100000000 +10111110010111000100001001001111001111100 +01100100101001111011110101001011000111111 +01111010011111111110011001110000000011100 +11000001000101010001011110111001000001011 +00110110000001010001001011110111000001100 +00011001101011010001111100101110111111010 +00101111000101110010011011111010100010000 +00110000010111000010111000110010100000001 +11000110101001001000111100101001100101110 +11010100010110000101000001111000101111001 +10110110010010110000101011110010110110010 +00110000001000010001011000000001000011000 +00101111111001011111001001010100000101000 +10110101110101011100101101001101111110111 +01011111101001111100010001111100010101000 +11001100010111000011011110101000010011011 +01111110011011010101101000101100100011111 +01101100011101111011010100001101010111011 +00000110100101000100011001011100000110010 +01111000001001000001110010101010000111010 +01100010011011100001100011100101100101110 +10100000010101011101101101001011001110001 +10010110111100100010110000010010000110010 +10101001110001011010010010111011010111000 +10101011010000101001011011010000111110111 +00000000101110000100111000111011100010011 +11111110000010000000000001110111101010010 +10000010100011000000010110000000100011000 +10111010111010011010001011100100111110010 +10111010110100110010100100001001100000011 +10111010110100001010101001011000111111100 +10000010001100101000111000000011101011010 +11111110101001011011001011110101000011100 +6 3 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm= +11111110101011110100110001111000101111111 +10000010110101110100011101001000101000001 +10111010011011010000000001111001001011101 +10111010100110100100101011111100001011101 +10111010010110110100000010110001001011101 +10000010000001011001000001000111001000001 +11111110101010101010101010101010101111111 +00000000101010101111100000101110000000000 +10110111001100011111010010010100001001011 +01100100101001111011110101001011000111111 +11001110101001001000101111000110110101010 +00011000011110001010000101100010011010000 +00110110000001010001001011110111000001100 +10101101011101100111001010011000001001100 +11110110011110101001000000100001111001011 +00110000010111000010111000110010100000001 +01110010011111111110001010011111010011000 +00001101001101011110011010100011110100010 +10110110010010110000101011110010110110010 +10000100111110100111101110110111110101110 +11110110100010000100010010001111011110011 +10110101110101011100101101001101111110111 +11101011011111001010100111001010100011110 +00010101001100011000000101110011001000000 +01111110011011010101101000101100100011111 +11011000101011001101100010111011100001101 +11011111111110011111000010000111011101001 +01111000001001000001110010101010000111010 +11010110101101010111010101010011010011000 +01111001001110000110110110010000010101010 +10010110111100100010110000010010000110010 +00011101000111101100100100001101100001110 +01110010001011110010000000001011111111100 +00000000101110000100111000111011100010011 +11111110110100110110110111000001101010100 +10000010111000011011001101011011100010011 +10111010011010011010001011100100111110010 +10111010100010000100010010111111010110101 +10111010101111010001110010000011100100111 +10000010001100101000111000000011101011010 +11111110111111101101111101000011110101010 +6 4 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm= +11111110111010000101000000001001001111111 +10000010010010110011011010001111101000001 +10111010001110000101010100101100001011101 +10111010101000101010100101110010001011101 +10111010110001110011000101110110001011101 +10000010101011110011101011101101101000001 +11111110101010101010101010101010101111111 +00000000110010010111011000010110100000000 +10001011100110110101111000111110111111001 +00010101011000001010000100111010110110001 +11110110010001110000010111111110001101101 +01001101001011011111010000110111001111010 +01000111110000100000111010000110110000010 +01101000011010100000001101011111001110100 +10100011001011111100010101110100101100001 +10111100011001001100110110111100101110000 +10110111011000111001001101011000010100000 +10100101100111110100110000001001011110111 +00111010011100111110100101111100111000011 +10111100000110011111010110001111001101001 +01011110001000101110111000100101110100110 +11000100000100101101011100111100001111001 +11010011100111110010011111110010011011001 +01000000011001001101010000100110011101010 +00001111101010100100011001011101010010001 +00011101101100001010100101111100100110101 +10001010101011001010010111010010001000011 +11110100000111001111111100100100001001011 +00010011101010010000010010010100010100000 +11010001100100101100011100111010111111111 +00011010110010101100111110011100001000011 +00100101111111010100011100110101011001001 +11011010100001011000101010100001111111001 +00000000111111110101001001001010100011101 +11111110101100001110001111111001101010011 +10000010001101001110011000001110100011001 +10111010101011101011111010010101111111100 +10111010000101000011010101111000010001101 +10111010011010000100100111010110110001101 +10000010000010100110110110001101100101011 +11111110111000101010111010000100110010010 +6 5 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm= +11111110000110011001011100010101001111111 +10000010110011010010111011101110001000001 +10111010100000001011011010100010001011101 +10111010111110011100010011000100101011101 +10111010000000000010110100000111101011101 +10000010001010010010001010001100001000001 +11111110101010101010101010101010101111111 +00000000101100001001000110001000100000000 +10000010110111000100001001001111011001110 +01011100010001000011001101110011111111000 +01111010011111111110011001110000000011100 +11010001010101000001001110101001010001001 +01011011101100111100100110011010101100001 +00001001111011000001101100111110101111000 +00101111000101110010011011111010100010000 +00001000101111111010000000001010011000110 +11000110101001001000111100101001100101110 +11000100000110010101010001101000111111011 +11011011111111011101000110011111011011111 +00100000011000000001001000010001010011010 +00101111111001011111001001010100000101000 +10001101001101100100010101110101000110000 +01011111101001111100010001111100010101000 +11011100000111010011001110111000000011001 +00010011110110111000000101000001001110010 +01111100001101101011000100011101000111001 +00000110100101000100011001011100000110010 +01000000110001111001001010010010111111101 +01100010011011100001100011100101100101110 +10110000000101001101111101011011011110011 +11111011010001001111011101111111101011111 +10111001100001001010000010101011000111010 +10101011010000101001011011010000111110111 +00000000110110111100000000000011100010100 +11111110000010000000000001110111101010010 +10000010010011010000000110010000100011010 +10111010010111110111100110001001111111111 +10111010000100100010110100011001110000001 +10111010010100001010101001011000111111100 +10000010010100010000000000111011010011101 +11111110101001011011001011110101000011100 +6 6 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm= +11111110100110011001011100010101001111111 +10000010110010110011011010001111101000001 +10111010101001000010010011101011001011101 +10111010011110011100010011000100101011101 +10111010100100100110010000100011001011101 +10000010000110011110000110000000001000001 +11111110101010101010101010101010101111111 +00000000001101101000100111101001000000000 +10011111111110001101000000000110010010111 +01011100010001000011001101110011111111000 +01011110111011011010111101010100100111000 +11011101011001001101000010100101011101000 +01011011101100111100100110011010101100001 +01101000011010100000001101011111001110100 +01100110001100111011010010110011101011001 +00001000101111111010000000001010011000110 +11100010001101101100011000001101000001010 +11001000001010011001011101100100110011010 +11011011111111011101000110011111011011111 +01000001111001100000101001110000110010110 +01100110110000010110000000011101001100001 +10001101001101100100010101110101000110000 +01111011001101011000110101011000110001100 +11010000001011011111000010110100001111000 +00010011110110111000000101000001001110010 +00011101101100001010100101111100100110101 +01001111101100001101010000010101001111011 +01000000110001111001001010010010111111101 +01000110111111000101000111000001000001010 +10111100001001000001110001010111010010010 +11111011010001001111011101111111101011111 +11011000000000101011100011001010100110110 +11100010011001100000010010011001111111110 +00000000110110111100000000000011100010100 +11111110100110100100100101010011101010110 +10000010111111011100001010011100100011011 +10111010110111110111100110001001111111111 +10111010100101000011010101111000010001101 +10111010011101000011100000010001110110101 +10000010010100010000000000111011010011101 +11111110101101111111101111010001100111000 +6 7 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm= +11111110010011001100001001000000001111111 +10000010001101001100100101110000001000001 +10111010011100010111000110111110001011101 +10111010000001100011101100111011001011101 +10111010010001110011000101110110001011101 +10000010111001100001111001111111101000001 +11111110101010101010101010101010101111111 +00000000010010010111011000010110100000000 +10010110101011011000010101010011010100000 +10100001101110111100110010001100000000111 +00001011101110001111101000000001110010010 +00100000100110110010111101011010100010111 +00001110111001101001110011001111111001011 +10010101100101011111110010100000110001011 +00110011011001101110000111100110111110011 +11110101010000000101111111110101100111001 +10110111011000111001001101011000010100000 +00110101110101100110100010011011001100101 +10001110101010001000010011001010001110101 +10111100000110011111010110001111001101001 +00110011100101000011010101001000011001011 +01110000110010011011101010001010111001111 +00101110011000001101100000001101100100110 +00101101110100100000111101001011110000111 +01000110100011101101010000010100011011000 +11100000010011110101011010000011011001010 +00011010111001011000000101000000011010001 +10111101001110000110110101101101000000010 +00010011101010010000010010010100010100000 +01000001110110111110001110101000101101101 +10101110000100011010001000101010111110101 +00100101111111010100011100110101011001001 +10110111001100110101000111001100111110100 +00000000101001000011111111111100100011011 +11111110010011110001110000000110101011100 +10000010100000100011110101100011100010100 +10111010000010100010110011011100111110101 +10111010111010111100101010000111101110010 +10111010001000010110110101000100100011111 +10000010001011101111111111000100101100010 +11111110111000101010111010000100110010010 +7 -1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&pe +111111100111101001101011001101111000101111111 +100000101111111100100001101100000001001000001 +101110101100110101100001110000110101001011101 +101110101101111001110110101110011101101011101 +101110100100100011001111110111110011101011101 +100000100000011000101000101000011000001000001 +111111101010101010101010101010101010101111111 +000000001111111000101000101010000110100000000 +100000101001001111101111110001010010111001110 +010010011001111010011111011010101011111110110 +101010111001000100111001101001000111011001110 +101011001011001010110100100010111110110011111 +111110110110001000110000100010001011001011000 +000100011100011000010000010011111001100100001 +101101100101101010001011110000100011001100110 +111111010111010001011011100001010111001111111 +011001111111000000101011110101010100011001101 +101001000110001111000110110001100100110011010 +111111100111011101101100100111101011001110001 +001011000001101001010101001001111010011011100 +111011111101010101101111110001100001111110000 +100110001011101011001000111001111111100011100 +010010101101011111101010101111011011101010110 +100110001010010001111000111110110110100011100 +110111111000100001101111100010001000111110010 +000011010000010100110010110111100101000101001 +010010111100101101101100001101101011010101110 +010000010110000010000011111000000101100101100 +001010111001111011000000011100110010010011001 +110100000101101010110000010101110100100100101 +110000111011001001111110001111101000101011101 +101101001101011111011011100100111001001001100 +001001101110001010000101111000010100011110110 +010110011100110010000100011011110111100111011 +000010100111111010101011011110011100010110010 +011110010110001010001101110001101101101100111 +100110110010000000001111100011101111111111011 +000000001101010101101000110100100100100010111 +111111100100110000001010101011001110101010110 +100000100111001010111000110100101110100011111 +101110100101000011001111101000110101111110010 +101110100110111111110001000101110100010000111 +101110100000100010101001100110101100100011101 +100000100111010101101001001110101001010011100 +111111101000101110000000110101110011110110010 +7 0 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&pe +111111100010111100111110011000101100101111111 +100000101010001001010100011001110101001000001 +101110100010111011101111111110111001001011101 +101110100010000110001001010001100001101011101 +101110101010101101001111111001111111101011101 +100000100101101101011000111101101100001000001 +111111101010101010101010101010101010101111111 +000000000010001101011000111111110011000000000 +101010100111000001101111111111011100100010010 +101101000110000101100000100101010100000001001 +100100110111001010110111100111001001010111111 +011110011110111111000001010111001011000110001 +101011100011011101100101110111011110011110010 +110001001001101101100101100110001100010001111 +100011101011100100000101111110101101000010111 +000000001000101110100100011110101000110000000 +010111110001001110100101111011011010010111100 +011100010011111010110011000100010001000110100 +101010110010001000111001110010111110011011011 +111110010100011100100000111100001111101110010 +110111111011011011101111111111101111111110001 +011010001100010100111000100110000000100010011 +011110101011010001101010100001010101101010111 +010010001111100100001000101011000011100010010 +100011111101110100111111110111011101111111000 +110110000101100001000111000010010000110000111 +011100110010100011100010000011100101011011111 +101111001001111101111100000111111010011010011 +000100110111110101001110010010111100011101000 +000001010000011111000101100000000001010001011 +100101101110011100101011011010111101111110111 +011000011000101010101110010001001100111100010 +000111100000000100001011110110011010010000111 +101001000011001101111011100100001000011000100 +000010101001110100100101010000010010011000011 +011110000011111111111000000100011000011001001 +100110100111010101011111110110111010111110001 +000000001000100000011000100001010001100011001 +111111100010111110001010100101000000101010111 +100000100000110101001000101011010001100010000 +101110101011001101001111100110111011111110011 +101110100011001010000100110000000001100101001 +101110101101110111111100110011111001110110111 +100000100010100000011100111011011100100110010 +111111101110100000001110111011111101111000011 +7 1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&pe +111111101111101001101011001101111000101111111 +100000100111011100000001001100100001001000001 +101110101111101110111010101011101101001011101 +101110100111010011011100000100110101101011101 +101110100111111000011111101100101011101011101 +100000101000111000001000101000111000001000001 +111111101010101010101010101010101010101111111 +000000000111011000001000101010100110000000000 +101000110010010100111111101010001001100100101 +111000010011010000110101110000000001010100011 +110001100010011111100010110010011100000010101 +001011001011101010010100000010011110010011011 +111110110110001000110000100010001011001011000 +100100011100111000110000110011011001000100101 +110110111110110001010000101011111000010111101 +010101011101111011110001001011111101100101010 +000010100100011011110000101110001111000010110 +001001000110101111100110010001000100010011110 +111111100111011101101100100111101011001110001 +101011000001001001110101101001011010111011000 +100011111110001110111111101010111010111111011 +001110001001000001101000110011010101100011001 +001010101110000100111010110100000000101011101 +000110001010110001011000111110010110100011000 +110111111000100001101111100010001000111110010 +100011010000110100010010010111000101100101101 +001001100111110110110111010110110000001110101 +111010011100101000101001010010101111001111001 +010001100010100000011011000111101001001000010 +010100000101001010010000110101010100000100001 +110000111011001001111110001111101000101011101 +001101001101111111111011000100011001101001000 +010010110101010001011110100011001111000101101 +111100010110011000101110110001011101001101110 +000010111100100001110000000101000111001101001 +011110010110101010101101010001001101001100011 +100110110010000000001111100011101111111111011 +000000001101110101001000110100000100100010011 +111111101111101011011010110000010101101011101 +100000100101100000011000111110000100100011010 +101110100110011000011111110011101110111111001 +101110100110011111010001100101010100110000011 +101110101000100010101001100110101100100011101 +100000100111110101001001101110001001110011000 +111111101011110101011011101110101000101101001 +7 2 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&pe +111111100100110010110000010110100000101111111 +100000100011111000100101101000000101001000001 +101110101100110101100001110000110101001011101 +101110101011110111111000100000010001101011101 +101110101100100011001111110111110011101011101 +100000101100011100101000101100011100001000001 +111111101010101010101010101010101010101111111 +000000001011111100101000101110000010100000000 +101111100001001111101111110001010010101111100 +011100010111110100010001010100100101110000111 +101010111001000100111001101001000111011001110 +101111001111001110110000100110111010110111111 +100101101101010011101011111001010000010000011 +000000011000011100010100010111111101100000001 +101101100101101010001011110000100011001100110 +110001011001011111010101101111011001000001110 +011001111111000000101011110101010100011001101 +101101000010001011000010110101100000110111010 +100100111100000110110111111100110000010101010 +001111000101101101010001001101111110011111100 +111011111101010101101111110001100001111110000 +101010001101100101001000110111110001100011101 +010010101101011111101010101111011011101010110 +100010001110010101111000111010110010100011100 +101111111011111010111111111001010011111111001 +000111010100010000110110110011100001000001001 +010010111100101101101100001101101011010101110 +011110011000001100001101110110001011101011101 +001010111001111011000000011100110010010011001 +110000000001101110110100010001110000100000101 +101011100000010010100101010100110011110000110 +101001001001011011011111100000111101001101100 +001001101110001010000101111000010100011110110 +011000010010111100001010010101111001101001010 +000010100111111010101011011110011100010110010 +011110010010001110001001110101101001101000111 +100110101001011011011111111000110100111110000 +000000001001010001101000110000100000100010111 +111111100100110000001010101011001110101010110 +100000101001000100111000111010100000100011110 +101110101101000011001111101000110101111110010 +101110101010111011110101000001110000010100111 +101110101011111001110010111101110111111000110 +100000100011010001101101001010101101010111100 +111111101000101110000000110101110011110110010 +7 3 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&pe +111111101100110010110000010110100000101111111 +100000101110010101001000000101101001001000001 +101110100010000011010111000110000001001011101 +101110101011110111111000100000010001101011101 +101110100001001110101111111010011111101011101 +100000100010101010011000111010101000001000001 +111111101010101010101010101010101010101111111 +000000001110010001001000100011101111000000000 +101101110111111001011111100111100100001001011 +011100010111110100010001010100100101110000111 +000111110100101001010100000100101010110100011 +011001011001111000000110010000001100000001001 +100101101101010011101011111001010000010000011 +101101010101110001111001111010010000001101100 +011011110011011100111101000110010101111010000 +110001011001011111010101101111011001000001110 +110100110010101101000110011000111001110100000 +011011010100111101110100000011010110000001100 +100100111100000110110111111100110000010101010 +100010001000000000111100100000010011110010001 +001111111011100011011111100111010111111110110 +101010001101100101001000110111110001100011101 +111110101000110010001010100010110110101011011 +010110001000100011001000101100000100100011010 +101111111011111010111111111001010011111111001 +101010011001111101011011011110001100101100100 +100100101010011011011010111011011101100011000 +011110011000001100001101110110001011101011101 +100111110100010110101101110001011111111110100 +000110010111011000000010100111000110010110011 +101011100000010010100101010100110011110000110 +000100000100110110110010001101010000100000001 +111111111000111100110011001110100010101000000 +011000010010111100001010010101111001101001010 +000010101010010111000110110011110001111011111 +011110000100111000111111000011011111011110001 +100110101001011011011111111000110100111110000 +000000001100111100001000111101001101100011010 +111111101010000110111010111101111000101010000 +100000101001000100111000111010100000100011110 +101110100000101110101111100101011000111111111 +101110101100001101000011110111000110100010001 +101110101011111001110010111101110111111000110 +100000100110111100000000100111000000111010001 +111111101110011000110110000011000101000000100 +7 4 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&pe +111111101000101110101100001010111100101111111 +100000100111100100111001110100011001001000001 +101110100111010110000010010011010101001011101 +101110101000010100011011000011110001101011101 +101110101000111111011111101011101111101011101 +100000101000000000111000110000000000001000001 +111111101010101010101010101010101010101111111 +000000001000011111001000101101100001000000000 +100010111101010011111111101101001110111111001 +000000001011101000001101001000111001101100100 +001001111010100111011010001010100100111010010 +001100001100101101010011000101011001010100011 +111001110001001111110111100101001100001100000 +011100000100000000001000001011100001111100010 +001110100110001001101000010011000000101111010 +010010011010111100110110001100111010100010010 +000101100011011100110111101001001000000101110 +110001011110010111011110101001111100101011001 +000111111111100101010100011111010011110110110 +101100000110001110110010101110011101111100000 +100111111001001001111111101101111101111110011 +110110001001111001011000101011101101100011110 +110010101110111100001010101100111000101011010 +000010001101110110011000111001010001100010000 +110011111111100110101111100101001111111111010 +011011001000001100101010101111111101011101010 +110001111111001110001111101110001000110110010 +111101011011101111101110010101101000001000001 +010110100101100111011100000000101110001111010 +101100011101110010101000001101101100111100110 +001000100011110001000110110111010000010011010 +001010001010111000111100000011011110101110000 +010101110010010110011001100100001000000010101 +000100001110100000010110001001100101110101001 +000010100100011001001000111101111111110101110 +011110010001101101101010010110001010001011011 +100110110101000111001111100100101000111110011 +000000001101001101111000101100111100100010100 +111111101111010011101010101000101101101011010 +100000100010100111011000111001000011100010010 +101110101001011111011111110100101001111110001 +101110100110100111101001011101101100001000100 +101110100000011010010001011110010100011011010 +100000100000110010001110101001001110110100000 +111111101100110010011100101001101111101010001 +7 5 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&pe +111111100111101001101011001101111000101111111 +100000101111111100100001101100000001001000001 +101110101100110101100001110000110101001011101 +101110101101111001110110101110011101101011101 +101110100100100011001111110111110011101011101 +100000100000011000101000101000011000001000001 +111111101010101010101010101010101010101111111 +000000001111111000101000101010000110100000000 +100000101001001111101111110001010010111001110 +010010011001111010011111011010101011111110110 +101010111001000100111001101001000111011001110 +101011001011001010110100100010111110110011111 +111110110110001000110000100010001011001011000 +000100011100011000010000010011111001100100001 +101101100101101010001011110000100011001100110 +111111010111010001011011100001010111001111111 +011001111111000000101011110101010100011001101 +101001000110001111000110110001100100110011010 +111111100111011101101100100111101011001110001 +001011000001101001010101001001111010011011100 +111011111101010101101111110001100001111110000 +100110001011101011001000111001111111100011100 +010010101101011111101010101111011011101010110 +100110001010010001111000111110110110100011100 +110111111000100001101111100010001000111110010 +000011010000010100110010110111100101000101001 +010010111100101101101100001101101011010101110 +010000010110000010000011111000000101100101100 +001010111001111011000000011100110010010011001 +110100000101101010110000010101110100100100101 +110000111011001001111110001111101000101011101 +101101001101011111011011100100111001001001100 +001001101110001010000101111000010100011110110 +010110011100110010000100011011110111100111011 +000010100111111010101011011110011100010110010 +011110010110001010001101110001101101101100111 +100110110010000000001111100011101111111111011 +000000001101010101101000110100100100100010111 +111111100100110000001010101011001110101010110 +100000100111001010111000110100101110100011111 +101110100101000011001111101000110101111110010 +101110100110111111110001000101110100010000111 +101110100000100010101001100110101100100011101 +100000100111010101101001001110101001010011100 +111111101000101110000000110101110011110110010 +7 6 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&pe +111111101111101001101011001101111000101111111 +100000101111100100111001110100011001001000001 +101110101110100111110011100010100101001011101 +101110100101111001110110101110011101101011101 +101110101101101010001111111110111011101011101 +100000100011011011101000101011011000001000001 +111111101010101010101010101010101010101111111 +000000000111100000111000110010011110100000000 +100111111011011101111111100011000000110010111 +010010011001111010011111011010101011111110110 +100011110000001101110000100000001110010000111 +101000001000001001110111100001111101110000111 +111110110110001000110000100010001011001011000 +011100000100000000001000001011100001111100010 +111111110111111000011001100010110001011110100 +111111010111010001011011100001010111001111111 +010000110110001001100010111100011101010000100 +101010000101001100000101110010100111110000010 +111111100111011101101100100111101011001110001 +010011011001110001001101010001100010000011111 +101011111111000111111111100011110011111110010 +100110001011101011001000111001111111100011100 +011010101100010110101010100110010010101011111 +100110001001010010111000111101110101100010100 +110111111000100001101111100010001000111110010 +011011001000001100101010101111111101011101010 +000000101110111111111110011111111001000111100 +010000010110000010000011111000000101100101100 +000011110000110010001001010101111011011010000 +110111000110101001110011010110110111100111101 +110000111011001001111110001111101000101011101 +110101010101000111000011111100100001010001111 +011011111100011000010111101010000110001100100 +010110011100110010000100011011110111100111011 +000010101110110011100010010111010101011111011 +011110010101001001001110110010101110101111111 +100110110010000000001111100011101111111111011 +000000001101001101111000101100111100100010100 +111111101110100010011010111001011100101010100 +100000101111001010111000110100101110100011111 +101110101100001010001111100001111100111111011 +101110101101111100110010000110110111010011111 +101110100000100010101001100110101100100011101 +100000100111001101110001010110110001001011111 +111111101010111100010010100111100001100100000 +7 7 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&pe +111111100010111100111110011000101100101111111 +100000100000011011000110001011100101001000001 +101110100011110010100110110111110001001011101 +101110100010000110001001010001100001101011101 +101110100000111111011111101011101111101011101 +100000101100100100011000110100100100001000001 +111111101010101010101010101010101010101111111 +000000000000011111001000101101100001000000000 +100101101110001000101111110110010101110100000 +101101000110000101100000100101010100000001001 +110110100101011000100101110101011011000101101 +010111010111110110001000011110000010001111000 +101011100011011101100101110111011110011110010 +100011011011111111110111110100011110000011101 +101010100010101101001100110111100100001011110 +000000001000101110100100011110101000110000000 +000101100011011100110111101001001000000101110 +010101011010110011111010001101011000001111101 +101010110010001000111001110010111110011011011 +101100000110001110110010101110011101111100000 +111111111010010010101111110110100110111111000 +011010001100010100111000100110000000100010011 +001110101001000011111010110011000111101010101 +011010001110101101001000100010001010100011011 +100011111101110100111111110111011101111111000 +100100010111110011010101010000000010100010101 +010101111011101010101011001010101100010010110 +101111001001111101111100000111111010011010011 +010110100101100111011100000000101110001111010 +001000011001010110001100101001001000011000010 +100101101110011100101011011010111101111110111 +001010001010111000111100000011011110101110000 +001110101001001101000010111111010011011001110 +101001000011001101111011100100001000011000100 +000010111011100110110111000010000000001010001 +011110001010110110110001001101010001010000000 +100110100111010101011111110110111010111110001 +000000001010110010001000110011000011100011011 +111111100011110111001010101100001001101011110 +100000101000110101001000101011010001100010000 +101110100001011111011111110100101001111110001 +101110101010000011001101111001001000101100000 +101110100101110111111100110011111001110110111 +100000100000110010001110101001001110110100000 +111111101111101001000111110010110100110001010 +8 -1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghij +1111111001101010100000011100001110000100101111111 +1000001000111100011101101011110000111111101000001 +1011101011011011001111000101110011010001101011101 +1011101010010000110000111000000101111101001011101 +1011101011001100101001111111001100010100001011101 +1000001011111100001010100010010001101110001000001 +1111111010101010101010101010101010101010101111111 +0000000010110110010011100011000001010110100000000 +1011111000000011011011111110010100101101101111100 +1010100001000000110101101001101000011100011011000 +1000111001011100101101011111110001000000010100111 +1100000011110010011010001000110010001101111010010 +1111101011001110001011011011001101011101101100010 +0100110111101011010100010100011110010100011010010 +0011001011111010001100001100000111110110000101011 +1011100100111000100010010010111111100110001010010 +0010011101000111000011101001001001001110110100110 +1110010001101100000100111101001011001101011100010 +1011111000000010101000000011110000101011010101011 +0100110000111010011100000000100011010100000110011 +1010101000010001000100111100000100011001111100100 +0110010001101100001100001100011010011101011100110 +1001111110010100101001111111100100101010111110001 +0011100010111010110011100011001100011010100011010 +0011101011110110111011101010010101011010101010100 +0011100010111011110000100011011000010100100011000 +0010111111011001110101111111011010011001111111011 +1100110010000000011101100011111011100000110100010 +0100001011101000011011000001010100001011001010001 +0000000011001011010101111000011100001000001001000 +1111101100000011011000010101000011101111111010111 +0101100111010110011011101010111110110100100101001 +0000111000111000100010111001011000001001100010100 +0000100000100111111101011110101101010000001001000 +0011111101101111110010111100010000101111101001011 +1111000000010000000101010101101011000110110000010 +0110011101001010011001011110011000111111101011111 +1010100111011011011101011101111010000101100100101 +0100011111011101011100110001000110101111011110111 +0111000110011101101101011110001001011000000000001 +1110001001111110111011111110001101011011111110101 +0000000010011101001101100011011100010101100011100 +1111111000111000001001101011111101001100101011011 +1000001011011111011111100011110010110111100010000 +1011101011000110111010111110010100111100111110010 +1011101011100111001101010001111000001100101110111 +1011101010100011110111011010110101111000010100000 +1000001000100001100100011100111111010110011101001 +1111111010110010000011011011000001001100010000111 +8 0 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghij +1111111000001001000011111111101101100100101111111 +1000001010100000000001110111101100100011101000001 +1011101000111000101100100110010000110001101011101 +1011101000001100101100100100011001100001001011101 +1011101010101111001010111110101111110100001011101 +1000001001100000010110100010001101110010001000001 +1111111010101010101010101010101010101010101111111 +0000000000101010001111100011011101001010100000000 +1010101001100000111000111111110111001110000010010 +0110110101011100101001110101110100000000000111011 +1011011010111111001110111100010010100011110111011 +0000010111101110000110010100101110010001100110001 +1100001000101101101000111000101110111110001111110 +1000100011110111001000001000000010001000000110001 +0000101000011001101111101111100100010101100110111 +0111110000100100111110001110100011111010010110001 +0001111110100100100000001010101010101101010111010 +0010000101110000011000100001010111010001000000001 +1000011011100001001011100000010011001000110110111 +1000100100100110000000011100111111001000011010000 +1001001011110010100111011111100111111010011111000 +1010000101110000010000010000000110000001000000101 +1010111111110111001010111110000111001001111111101 +1111100010100110101111100011010000000110100011001 +0000101010010101011000101011110110111001101011000 +1111100010100111101100100011000100001000100011011 +0001111110111010010110111110111001111010111110111 +0000100110011100000001111111100111111100101000001 +0111101000001011111000100010110111101000101001101 +1100010111010111001001100100000000010100010101011 +1100001111100000111011110110100000001100011001011 +1001110011001010000111110110100010101000111001010 +0011011011011011000001011010111011101010000001000 +1100110100111011100001000010110001001100010101011 +0000011110001100010001011111110011001100001010111 +0011010100001100011001001001110111011010101100001 +0101111110101001111010111101111011011100001000011 +0110110011000111000001000001100110011001111000110 +0100011100111110111111010010100101001100111101011 +0111000010000001110001000010010101000100011100010 +1110001010011101011000111111101110111000111111001 +0000000010000001010001100011000000001001100011111 +1111111001011011101010101010011110101111101010111 +1000001001000011000011100011101110101011100010011 +1011101010100101011001111111110111011111111111110 +1011101001111011010001001101100100010000110010100 +1011101011000000010100111001010110011011110111100 +1000001000111101111000000000100011001010000001010 +1111111011010001100000111000100010101111110011011 +8 1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghij +1111111011011100010110101010111000110000101111111 +1000001001110101010100100010111001110111101000001 +1011101011101101111001110011000101100101101011101 +1011101001011001111001110001001100110101001011101 +1011101001111010011111111111111010100000001011101 +1000001010110101000011100011011000100110001000001 +1111111010101010101010101010101010101010101111111 +0000000001111111011010100010001000011111100000000 +1010001100110101101101111110100010011011000100101 +0011100000001001111100100000100001010101010010001 +1110001111101010011011101001000111110110100010001 +0101000010111011010011000001111011000100110011011 +1001011101111000111101101101111011101011011010100 +1101110110100010011101011101010111011101010011011 +0101111101001100111010111010110001000000110011101 +0010100101110001101011011011110110101111000011011 +0100101011110001110101011111111111111000000010000 +0111010000100101001101110100000010000100010101011 +1101001110110100011110110101000110011101100011101 +1101110001110011010101001001101010011101001111010 +1100011110100111110010001010110010101111001010010 +1111010000100101000101000101010011010100010101111 +1111111110100010011111111111010010011100111110111 +1010100011110011111010100010000101010011100010011 +0101101011000000001101101010100011101100101010010 +1010100011110010111001100010010001011101100010001 +0100111111101111000011111111101100101111111111101 +0101110011001001010100101010110010101001111101011 +0010111101011110101101110111100010111101111100111 +1001000010000010011100110001010101000001000000001 +1001011010110101101110100011110101011001001100001 +1100100110011111010010100011110111111101101100000 +0110001110001110010100001111101110111111010100010 +1001100001101110110100010111100100011001000000001 +0101001011011001000100001010100110011001011111101 +0110000001011001001100011100100010001111111001011 +0000101011111100101111101000101110001001011101001 +0011100110010010010100010100110011001100101101100 +0100011001101011101010000111110000011001101000001 +0111000111010100100100010111000000010001001001000 +1110001111001000001101111110111011101101111110011 +0000000011010100000100100010010101011100100010101 +1111111010001110111111101011001011111010101011101 +1000001000010110010110100010111011111110100011001 +1011101001110000001100111110100010001010111110100 +1011101000101110000100011000110001000101100111110 +1011101010010101000001101100000011001110100010110 +1000001001101000101101010101110110011111010100000 +1111111010000100110101101101110111111010100110001 +8 2 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghij +1111111001101010100000011100001110000100101111111 +1000001000111100011101101011110000111111101000001 +1011101011011011001111000101110011010001101011101 +1011101010010000110000111000000101111101001011101 +1011101011001100101001111111001100010100001011101 +1000001011111100001010100010010001101110001000001 +1111111010101010101010101010101010101010101111111 +0000000010110110010011100011000001010110100000000 +1011111000000011011011111110010100101101101111100 +1010100001000000110101101001101000011100011011000 +1000111001011100101101011111110001000000010100111 +1100000011110010011010001000110010001101111010010 +1111101011001110001011011011001101011101101100010 +0100110111101011010100010100011110010100011010010 +0011001011111010001100001100000111110110000101011 +1011100100111000100010010010111111100110001010010 +0010011101000111000011101001001001001110110100110 +1110010001101100000100111101001011001101011100010 +1011111000000010101000000011110000101011010101011 +0100110000111010011100000000100011010100000110011 +1010101000010001000100111100000100011001111100100 +0110010001101100001100001100011010011101011100110 +1001111110010100101001111111100100101010111110001 +0011100010111010110011100011001100011010100011010 +0011101011110110111011101010010101011010101010100 +0011100010111011110000100011011000010100100011000 +0010111111011001110101111111011010011001111111011 +1100110010000000011101100011111011100000110100010 +0100001011101000011011000001010100001011001010001 +0000000011001011010101111000011100001000001001000 +1111101100000011011000010101000011101111111010111 +0101100111010110011011101010111110110100100101001 +0000111000111000100010111001011000001001100010100 +0000100000100111111101011110101101010000001001000 +0011111101101111110010111100010000101111101001011 +1111000000010000000101010101101011000110110000010 +0110011101001010011001011110011000111111101011111 +1010100111011011011101011101111010000101100100101 +0100011111011101011100110001000110101111011110111 +0111000110011101101101011110001001011000000000001 +1110001001111110111011111110001101011011111110101 +0000000010011101001101100011011100010101100011100 +1111111000111000001001101011111101001100101011011 +1000001011011111011111100011110010110111100010000 +1011101011000110111010111110010100111100111110010 +1011101011100111001101010001111000001100101110111 +1011101010100011110111011010110101111000010100000 +1000001000100001100100011100111111010110011101001 +1111111010110010000011011011000001001100010000111 +8 3 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghij +1111111011101010100000011100001110000100101111111 +1000001011100111000110110000101011100111101000001 +1011101000110110100010101000011110111101101011101 +1011101010010000110000111000000101111101001011101 +1011101000010111110010111110010111001100001011101 +1000001000010001100111100011111100000010001000001 +1111111010101010101010101010101010101010101111111 +0000000011101101001000100010011010001101100000000 +1011011101101110110110111111111001000000001001011 +1010100001000000110101101001101000011100011011000 +0011101010000111110110000100101010011011001111100 +0001100110011111110111100101011111100000010111111 +1111101011001110001011011011001101011101101100010 +1111100100110000001111001111000101001111000001001 +1110101110010111100001100001101010011011101000110 +1011100100111000100010010010111111100110001010010 +1001001110011100011000110010010010010101101111101 +0011110100000001101001010000100110100000110001111 +1011111000000010101000000011110000101011010101011 +1111100011100001000111011011111000001111011101000 +0111001101111100101001010001101001110100010001001 +0110010001101100001100001100011010011101011100110 +0010111111001111110010111110111111110001111111010 +1110100011010111011110100010100001110111100010111 +0011101011110110111011101010010101011010101010100 +1000100011100000101011100010000011001111100010011 +1111111110110100011000111110110111110100111110110 +1100110010000000011101100011111011100000110100010 +1111011000110011000000011010001111010000010001010 +1101100110100110111000010101110001100101100100101 +1111101100000011011000010101000011101111111010111 +1110110100001101000000110001100101101111111110010 +1101011101010101001111010100110101100100001111001 +0000100000100111111101011110101101010000001001000 +1000101110110100101001100111001011110100110010000 +0010100101111101101000111000000110101011011101111 +0110011101001010011001011110011000111111101011111 +0001110100000000000110000110100001011110111111110 +0100011010110000110001011100101011000010110011010 +0111000110011101101101011110001001011000000000001 +1110001010100101100000111111010110000000111111110 +0000000011110000100000100010110001111000100010001 +1111111010111000001001101011111101001100101011011 +1000001010000100000100100010101001101100100011011 +1011101000101011010111111111111001010001111111111 +1011101011100111001101010001111000001100101110111 +1011101011111000101100000001101110100011001111011 +1000001001001100001001110001010010111011110000100 +1111111010110010000011011011000001001100010000111 +8 4 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghij +1111111010101101100111011011001001000000101111111 +1000001001111011011010101100110111111011101000001 +1011101001100011110111111101001011101001101011101 +1011101010101000001000000000111101000101001011101 +1011101010001011101110111110001011010000001011101 +1000001010111011001101100011010110101010001000001 +1111111010101010101010101010101010101010101111111 +0000000010001110101011100011111001101110000000000 +1000101111000100011100111111010011101010111111001 +1101100110000111110010101110101111011011011100000 +0000001001100100010101100111001001111000101100000 +0100110011001010100010110000001010110101000010101 +1000101100001001001100011100001010011010101011010 +0011110000101100010011010011011001010011011101010 +1011111011000010110100110100111111001110111101100 +0011010100000000011010101010000111011110110010101 +0101011010000000000100101110001110001001110011110 +1001010110101011000011111010001100001010011011010 +0011001000111010010000111011001000010011101101100 +1100000000000010100100111000011011101100111110100 +1101101111010110000011111011000011011110111011100 +0001010110101011001011001011011101011010011011110 +0001111110101100010001111111011100010010111110110 +1011100010000010001011100011110100100010100011101 +0100101010110001111100101011010010011101101011100 +0100100011111100110111100010011111010011100010000 +1010111111100001001101111111100010100001111111100 +0100000010111000100101011011000011011000001100101 +0011001100101111011100000110010011001100001101001 +0111000100001100010010111111011011001111001110000 +0111011100111011100000101101111011010111000010000 +1101010111101110100011010010000110001100011101110 +0111111111111111100101111110011111001110100101100 +0111100111100000111010011001101010010111001110000 +1011001101010111001010000100101000010111010001100 +0111110000101000111101101101010011111110001000101 +0001011010001101011110011001011111111000101100111 +1101100000011100011010011010111101000010100011101 +0100011111100101100100001001111110010111100110000 +0111000110100101010101100110110001100000111000110 +1110001110111001111100111111001010011100111111101 +0000000011011010001010100010011011010010100010100 +1111111010000000110001101011000101110100101011100 +1000001001100111100111100011001010001111100010111 +1011101010000001111101111111010011111011111111010 +1011101000100000001010010110111111001011101001111 +1011101000011011001111100010001101000000101100111 +1000001000011001011100100100000111101110100101110 +1111111011110101000100011100000110001011010111111 +8 5 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghij +1111111001011100010110101010111000110000101111111 +1000001011111101011100101010110001111111101000001 +1011101011011011001111000101110011010001101011101 +1011101011110011010011011011100110011101001011101 +1011101001001100101001111111001100010100001011101 +1000001000111101001011100011010000101110001000001 +1111111010101010101010101010101010101010101111111 +0000000011110111010010100010000000010111100000000 +1000001010000011011011111110010100101101111001110 +1001000010100011010110001010001011111111111000100 +1000111001011100101101011111110001000000010100111 +1101000010110011011011001001110011001100111011010 +1001011101111000111101101101111011101011011010100 +0101110110101010010101010101011111010101011011010 +0011001011111010001100001100000111110110000101011 +1000000111011011000001110001011100000101101001110 +0010011101000111000011101001001001001110110100110 +1111010000101101000101111100001010001100011101010 +1101001110110100011110110101000110011101100011101 +0101110001111011011101000001100010010101000111011 +1010101000010001000100111100000100011001111100100 +0101110010001111101111101111111001111110111111010 +1001111110010100101001111111100100101010111110001 +0010100011111011110010100010001101011011100010010 +0101101011000000001101101010100011101100101010010 +0010100011111010110001100010011001010101100010000 +0010111111011001110101111111011010011001111111011 +1111010001100011111110000000011000000011010111110 +0100001011101000011011000001010100001011001010001 +0001000010001010010100111001011101001001001000000 +1001011010110101101110100011110101011001001100001 +0100100110010111011010101011111111110101100100001 +0000111000111000100010111001011000001001100010100 +0011000011000100011110111101001110110011101010100 +0011111101101111110010111100010000101111101001011 +1110000001010001000100010100101010000111110001010 +0000101011111100101111101000101110001001011101001 +1011100110011010011100011100111011000100100101101 +0100011111011101011100110001000110101111011110111 +0111000101111110001110111101101010111011100011101 +1110001001111110111011111110001101011011111110101 +0000000011011100001100100010011101010100100010100 +1111111000001110111111101011001011111010101011101 +1000001000011110011110100010110011110110100011000 +1011101001000110111010111110010100111100111110010 +1011101000000100101110110010011011101111001101011 +1011101000100011110111011010110101111000010100000 +1000001001100000100101011101111110010111011100001 +1111111010000100110101101101110111111010100110001 +8 6 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghij +1111111011011100010110101010111000110000101111111 +1000001011111011011010101100110111111011101000001 +1011101011111111101011100001010111110101101011101 +1011101001110011010011011011100110011101001011101 +1011101011011110111011111111011110000100001011101 +1000001000001101111011100011100000011110001000001 +1111111010101010101010101010101010101010101111111 +0000000001110001010100100010000110010001100000000 +1001111110100111111111111110110000001001010010111 +1001000010100011010110001010001011111111111000100 +1010101011001110111111001101100011010010000110101 +1101110010000011101011111001000011111100001011100 +1001011101111000111101101101111011101011011010100 +0011110000101100010011010011011001010011011101010 +0111101111011110101000101000100011010010100001111 +1000000111011011000001110001011100000101101001110 +0000001111010101010001111011011011011100100110100 +1111100000011101110101001100111010111100101101100 +1101001110110100011110110101000110011101100011101 +0011110111111101011011000111100100010011000001011 +1110001100110101100000011000100000111101011000000 +0101110010001111101111101111111001111110111111010 +1011111110000110111011111111110110111000111110011 +0010100011001011000010100010111101101011100010100 +0101101011000000001101101010100011101100101010010 +0100100011111100110111100010011111010011100010000 +0110111111111101010001111111111110111101111111111 +1111010001100011111110000000011000000011010111110 +0110011001111010001001010011000110011001011000011 +0001110010111010100100001001101101111001111000110 +1001011010110101101110100011110101011001001100001 +0010100000010001011100101101111001110011100010001 +0100011100011100000110011101111100101101000110000 +0011000011000100011110111101001110110011101010100 +0001101111111101100000101110000010111101111011001 +1110110001100001110100100100011010110111000001100 +0000101011111100101111101000101110001001011101001 +1101100000011100011010011010111101000010100011101 +0100011011111001111000010101100010001011111010011 +0111000101111110001110111101101010111011100011101 +1110001011101100101001111110011111001001111110111 +0000000011101100111100100010101101100100100010010 +1111111010001110111111101011001011111010101011101 +1000001010011000011000100010110101110000100011000 +1011101011100010011110111110110000011000111110110 +1011101010000100101110110010011011101111001101011 +1011101000110001100101001000100111101010000110010 +1000001001010000010101101101001110100111101100111 +1111111010000100110101101101110111111010100110001 +8 7 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghij +1111111000001001000011111111101101100100101111111 +1000001000000100100101010011001000000111101000001 +1011101000101010111110110100000010100001101011101 +1011101000001100101100100100011001100001001011101 +1011101000001011101110111110001011010000001011101 +1000001011110010000100100010011111100010001000001 +1111111010101010101010101010101010101010101111111 +0000000000001110101011100011111001101110000000000 +1001011011110010101010111111100101011100010100000 +0110110101011100101001110101110100000000000111011 +1111111110011011101010011000110110000111010011111 +0010000101111100010100000110111100000011110100011 +1100001000101101101000111000101110111110001111110 +1100000111010011101100101100100110101100100010101 +0010111010001011111101111101110110000111110100101 +0111110000100100111110001110100011111010010110001 +0101011010000000000100101110001110001001110011110 +0000010111100010001010110011000101000011010010011 +1000011011100001001011100000010011001000110110111 +1100000000000010100100111000011011101100111110100 +1011011001100000110101001101110101101000001101010 +1010000101110000010000010000000110000001000000101 +1110111111010011101110111110100011101101111111001 +1101100010110100111101100011000010010100100011011 +0000101010010101011000101011110110111001101011000 +1011100010000011001000100011100000101100100011111 +0011111110101000000100111110101011101000111110101 +0000100110011100000001111111100111111100101000001 +0011001100101111011100000110010011001100001101001 +1110000101000101011011110110010010000110000111001 +1100001111100000111011110110100000001100011001011 +1101010111101110100011010010000110001100011101110 +0001001001001001010011001000101001111000010011010 +1100110100111011100001000010110001001100010101011 +0100111010101000110101111011010111101000101110011 +0001000110011110001011011011100101001000111110011 +0101111110101001111010111101111011011100001000011 +0010010111100011100101100101000010111101011100010 +0100011110101100101101000000110111011110101111001 +0111000010000001110001000010010101000100011100010 +1110001110111001111100111111001010011100111111101 +0000000010010011000011100011010010011011100011101 +1111111001011011101010101010011110101111101010111 +1000001011100111100111100011001010001111100010111 +1011101000110111001011111111100101001101111111100 +1011101011111011010001001101100100010000110010100 +1011101001100100110000011101110010111111010011000 +1000001000101111101010010010110001011000010011000 +1111111011010001100000111000100010101111110011011 +9 -1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL +11111110111110101000010111001011100011010110001111111 +10000010110110111110111001100010001001000011001000001 +10111010011001010000011010010101111111000001001011101 +10111010101110111001100100010001010110000110101011101 +10111010001110101010011111111001110000100010001011101 +10000010001001011110000010001011011101101010001000001 +11111110101010101010101010101010101010101010101111111 +00000000110100011110010010001010100010101010000000000 +10110111010110001110001111111100010001111110101001011 +01001001111000101000001110001010100011000011000111101 +11101010110111000111111110110010011001110110111000010 +10000100110010101001111100010000110110001000010010010 +00100111111001001010110010101110000111000000101110101 +10010001011010011001011110000000100001101000110011101 +11101011100001101110100101111001110000100011010100011 +01101000101001000111010111010100100011001110010001001 +00011111010001110000000010100110101001110111011000100 +11111101100110100101110101010101111110001100011001001 +00101110110110110011111000110001101011100001100111000 +01100101000101011110101001100100101010111100101010100 +11110110001110110010011101111110011100011111111000100 +00000100001000101001010011010110000101001010000101010 +01000111101100001001100100110000101101011010001110110 +10000000010110100101001101001001101110110111100110001 +11011111100110111011000011111111010111100010111110110 +11101000110101100110001110001000010101101001100011101 +00101010110000000100101010101011101101011010101011001 +01101000110101101011001110001011100000001100100011000 +00001111101010101001010011111101100001010010111111010 +10110101000111100011001100111000011110010100001001000 +11001011101111000001101011001100111101010101100111100 +10110100000100000001010010000101000110100001001011100 +01101010000100000000011000000010010101111101111111111 +11001100110101110110000100010110110000000110010110111 +11111111011101001111001101101101111101011011010000110 +01001101001010110111011010011001111111010011100000000 +10010011101100011100000001001111001010100110010001010 +00111000110110000111101111010101010001111000111000100 +00000110101000000100100011011010110011110110011010011 +00111101011110101101101110100011001100011101111110001 +01101011101010001001110101100101111000010000000110000 +10001000100010110010001110000101011100000001101100011 +11011110011000110011110010101110111011101100100001000 +01100001001001010001110101100011010010101011110001100 +00010011010000111101011111111001011101101101111111111 +00000000110000110111111010001110100101011110100010011 +11111110110100111111111110101111000100010110101010110 +10000010100111000111001110001000100010010101100010001 +10111010010111000001110011111111000111100011111110101 +10111010111101100111100011110100110001111000010101011 +10111010101011001001001100111111100010001010010100100 +10000010010010100010001101010010110011101111000011010 +11111110110010100001111101110010100001100100001100010 +9 0 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL +11111110000110010000101111110011011011101110001111111 +10000010100111001111001000010011111000110011001000001 +10111010011010110011111001110110011100100001001011101 +10111010001001111110100011010110010001000010101011101 +10111010100000100100010011111111111110101110001011101 +10000010010101000010011110001111000001110110001000001 +11111110101010101010101010101010101010101010101111111 +00000000000101101111100010001011010011011011100000000 +10101010010101101101101111111111110010011101000010010 +10001100111111101111001001001101100100000100000000101 +01100110111001001001110000111100010111111000110110011 +10011000101110110101100000001100101010010100001110001 +00011111000001110010001010010110111111111000010110010 +11100000101011101000101111110001010000011001000010011 +00001010000010001101000110011010010011000000110111111 +10101101101110000000010000010011100100001001010110001 +10010011011111111110001100101000100111111001010110101 +11100001111010111001101001001001100010010000000101010 +00010110001110001011000000001001010011011001011111111 +00010100110100101111011000010101011011001101011011010 +00010111101101010001111110011101111111111100011011000 +11000001001111101110010100010001000010001101000010010 +11001011100010000111101010111110100011010100000000111 +10011100001010111001010001010101110010101011111010010 +11101111111110000011111011111111101111011010111110001 +10011000100100010111111110001001100100011000100010011 +11001010110011100111001010101000001110111001101010101 +10101000110010101100001010001100100111001011100010000 +10001111100100100111011111111011101111011100111111011 +10101001011011111111010000100100000010001000010101011 +11110011010111111001010011110100000101101101011111011 +11000101110101110000100011110100110111010000111010010 +10001011100111100011111011100001110110011110011100011 +00001001110010110001000011010001110111000001010001111 +01110011010011000001000011100011110011010101011110111 +01010001010110101011000110000101100011001111111100011 +10101011010100100100111001110111110010011110101001101 +01001001000111110110011110100100100000001001001001010 +11100111001011100111000000111001010000010101111001111 +11111000011001101010101001100100001011011010111001001 +11100111100100000111111011101011110110011110001000001 +10010100111110101110010010011001000000011101110000000 +11011110100000001011001010010110000011010100011001111 +01100000111000100000000100010010100011011010000000010 +00010010110011011110111111111010111110001110111110011 +00000000110111110000111110001001100010011001100011011 +11111110011010110001110010101001001010011000101010111 +10000010011011011011010010001100111110001001100010010 +10111010101111111001001011111111111111011011111110010 +10111010001100010110010010000101000000001001100100101 +10111010101000101010101111011100000001101001110111000 +10000010010101100101001010010101110100101000000100010 +11111110111100101111110011111100101111101010000010011 +9 1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL +11111110110011000101111010100110001110111010001111111 +10000010010010011010011101000110101101100111001000001 +10111010101111100110101100100011001001110101001011101 +10111010011100101011110110000011000100010110101011101 +10111010010101110001000111111010101011111010001011101 +10000010100000010111001010001010010100100010001000001 +11111110101010101010101010101010101010101010101111111 +00000000010000111010110110001110000110001110100000000 +10100011000000111000111011111010100111001000000100101 +11011001101010111010011100011000110001010001010101111 +00110011101100011100100101101001000010101101100011001 +11001101111011100000110101011001111111000001011011011 +01001010010100100111011111000011101010101101000011000 +10110101111110111101111010100100000101001100010111001 +01011111010111011000010011001111000110010101100010101 +11111000111011010101000101000110110001011100000011011 +11000110001010101011011001111101110010101100000011111 +10110100101111101100111100011100110111000101010000000 +01000011011011011110010101011100000110001100001010101 +01000001100001111010001101000000001110011000001110000 +01000010111000000100101011001000101010101001001110010 +10010100011010111011000001000100010111011000010111000 +10011110110111010010111111101011110110000001010101101 +11001001011111101100000100000000100111111110101111000 +10111111101011010110101111111010111010001111111111011 +11001000110001000010101010001100110001001101100011001 +10011010100110110010011110101101011011101100101011111 +11111000100111111001011110001001110010011110100011010 +11011111110001110010001011111110111010001001111110001 +11111100001110101010000101110001010111011101000000001 +10100110000010101100000110100001010000111000001010001 +10010000100000100101110110100001100010000101101111000 +11011110110010110110101110110100100011001011001001001 +01011100100111100100010110000100100010010100000100101 +00100110000110010100010110110110100110000000001011101 +00000100000011111110010011010000110110011010101001001 +11111110000001110001101100100010100111001011111100111 +00011100010010100011001011110001110101011100011100000 +10110010011110110010010101101100000101000000101100101 +10101101001100111111111100110001011110001111101100011 +10110010110001010010101110111110100011001011011101011 +11000001101011111011000111001100010101001000100101010 +11011111110101011110011111000011010110000001001100101 +01100001101101110101010001000111110110001111010101000 +00010011100110001011101011111111101011011011111111001 +00000000100010100101101010001100110111001100100010001 +11111110101111100100100110101100011111001101101011101 +10000010001110001110000110001001101011011100100011000 +10111010011010101100011111111010101010001110111111000 +10111010011001000011000111010000010101011100110001111 +10111010111101111111111010001001010100111100100010010 +10000010000000110000011111000000100001111101010001000 +11111110101001111010100110101001111010111111010111001 +9 2 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL +11111110011110101000010111001011100011010110001111111 +10000010000000001000001111010100111111110111001000001 +10111010100010001011000001001110100100011001001011101 +10111010101110111001100100010001010110000110101011101 +10111010111000011100101011111111000110010110001011101 +10000010110010000101011010001000000110110010001000001 +11111110101010101010101010101010101010101010101111111 +00000000100010101000100110001100010100011100100000000 +10111110001101010101010111111111001010100101101111100 +01001001111000101000001110001010100011000011000111101 +01011110000001110001001000000100101111000000001110100 +01011101101001110010100111001011101101010011001001001 +00100111111001001010110010101110000111000000101110101 +00100101101100101111101000110110010111011110000101011 +00110010111010110101111110100010101011111000001111000 +01101000101001000111010111010100100011001110010001001 +10101011100111000110110100010000011111000001101110010 +00100100111101111110101110001110100101010111000010010 +00101110110110110011111000110001101011100001100111000 +11010001110011101000011111010010011100001010011100010 +00101111010101101001000110100101000111000100100011111 +00000100001000101001010011010110000101001010000101010 +11110011011010111111010010000110011011101100111000000 +01011001001101111110010110010010110101101100111101010 +11011111100110111011000011111111010111100010111110110 +01011000100011010000111010001110100011011111100011011 +11111010101011011111110010101000110110000001101010010 +01101000110101101011001110001011100000001100100011000 +10111111111100011111100111111011010111100100111111100 +01101100011100111000010111100011000101001111010010011 +11001011101111000001101011001100111101010101100111100 +00000000110010110111100100110011110000010111111101010 +10110011011111011011000011011001001110100110100100100 +11001100110101110110000100010110110000000110010110111 +01001011101011111001111011011011001011101101100110000 +10010100010001101100000001000010100100001000111011011 +10010011101100011100000001001111001010100110010001010 +10001100000000110001011001100011100111001110001110010 +11011111110011011111111000000001101000101101000001000 +00111101011110101101101110100011001100011101111110001 +11011111011100111111000011010011001110100110110000110 +01010001111001101001010101011110000111011010110111000 +11011110011000110011110010101110111011101100100001000 +01100001111111100111000011010101100100011101000111010 +00010010001011100110000111111010000110110110111110100 +00000000110000110111111010001110100101011110100010011 +11111110000010001001001010101001110010100000101010000 +10000010111100011100010110001011111001001110100011010 +10111010110111000001110011111111000111100011111110101 +10111010101011010001010101000010000111001110100011101 +10111010110000010010010111100100111001010001001111111 +10000010010010100010001101010010110011101111000011010 +11111110100100010111001011000100010111010010111010100 +9 3 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL +11111110111110101000010111001011100011010110001111111 +10000010110110111110111001100010001001000011001000001 +10111010011001010000011010010101111111000001001011101 +10111010101110111001100100010001010110000110101011101 +10111010001110101010011111111001110000100010001011101 +10000010001001011110000010001011011101101010001000001 +11111110101010101010101010101010101010101010101111111 +00000000110100011110010010001010100010101010000000000 +10110111010110001110001111111100010001111110101001011 +01001001111000101000001110001010100011000011000111101 +11101010110111000111111110110010011001110110111000010 +10000100110010101001111100010000110110001000010010010 +00100111111001001010110010101110000111000000101110101 +10010001011010011001011110000000100001101000110011101 +11101011100001101110100101111001110000100011010100011 +01101000101001000111010111010100100011001110010001001 +00011111010001110000000010100110101001110111011000100 +11111101100110100101110101010101111110001100011001001 +00101110110110110011111000110001101011100001100111000 +01100101000101011110101001100100101010111100101010100 +11110110001110110010011101111110011100011111111000100 +00000100001000101001010011010110000101001010000101010 +01000111101100001001100100110000101101011010001110110 +10000000010110100101001101001001101110110111100110001 +11011111100110111011000011111111010111100010111110110 +11101000110101100110001110001000010101101001100011101 +00101010110000000100101010101011101101011010101011001 +01101000110101101011001110001011100000001100100011000 +00001111101010101001010011111101100001010010111111010 +10110101000111100011001100111000011110010100001001000 +11001011101111000001101011001100111101010101100111100 +10110100000100000001010010000101000110100001001011100 +01101010000100000000011000000010010101111101111111111 +11001100110101110110000100010110110000000110010110111 +11111111011101001111001101101101111101011011010000110 +01001101001010110111011010011001111111010011100000000 +10010011101100011100000001001111001010100110010001010 +00111000110110000111101111010101010001111000111000100 +00000110101000000100100011011010110011110110011010011 +00111101011110101101101110100011001100011101111110001 +01101011101010001001110101100101111000010000000110000 +10001000100010110010001110000101011100000001101100011 +11011110011000110011110010101110111011101100100001000 +01100001001001010001110101100011010010101011110001100 +00010011010000111101011111111001011101101101111111111 +00000000110000110111111010001110100101011110100010011 +11111110110100111111111110101111000100010110101010110 +10000010100111000111001110001000100010010101100010001 +10111010010111000001110011111111000111100011111110101 +10111010111101100111100011110100110001111000010101011 +10111010101011001001001100111111100010001010010100100 +10000010010010100010001101010010110011101111000011010 +11111110110010100001111101110010100001100100001100010 +9 4 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL +11111110101111011001100110111010010010100110001111111 +10000010010001111001111110100101001110000111001000001 +10111010001100000101001111000000101010010101001011101 +10111010100000110111101010011111011000001010101011101 +10111010101001101101011011111110110111100110001011101 +10000010100011110100101010001001110111000010001000001 +11111110101010101010101010101010101010101010101111111 +00000000101100100110101010001010011010010010100000000 +10001011111100100100100111111110111011010100011111001 +00111000001001011001111111111011010010110010110110011 +11010010001111111111000110001010100001001110000000101 +11010001100111111100101001000101100011011101000111000 +01010110001000111011000011011111110110110001011111011 +01010100011101011110011001000111100110101111110100101 +10111110110100111011110000101100100101110110000001001 +11100100100111001001011001011010101101000000011111000 +11011010010110110111000101100001101110110000011111100 +01010101001100001111011111111111010100100110110011100 +10100010111000111101110110111111100101101111101001001 +01011101111101100110010001011100010010000100010010011 +01011110100100011000110111010100110110110101010010001 +01110101111001011000100010100111110100111011110100100 +01111111010100110001011100001000010101100010110110001 +11010101000011110000011000011100111011100010110011011 +10101111110111001010110011111110100110010011111111000 +00101000110010100001001010001111010010101110100010101 +01111010100101010001111110101110111000001111101010011 +11101000111011100101000010001101101110000010100011001 +11001111101101101110010111111010100110010101111110010 +00011101101101001001100110010010110100111110100011101 +01000111100001001111100101000010110011011011101001101 +10001100111100111001101010111101111110011001110011011 +11000010101110101010110010101000111111010111010101010 +10111101000100000111110101100111000001110111100111001 +11000111100101110111110101010101000101100011101000001 +00011000011111100010001111001100101010000110110101010 +11100010011101101101110000111110111011010111100000100 +11111101110001000000101000010010010110111111111111100 +01010011111101010001110110001111100110100011001111001 +10110001010000100011100000101101000010010011110000000 +10101110101101001110110010100010111111010111000001000 +00100000001000011000100100101111110110101011000110110 +11011110010110111101111100100000110101100010101111001 +01100001110001101001001101011011101010010011001001011 +00010011111010010111110111111011110111000111111111010 +00000000100001000110001010001111010100101111100011101 +11111110101100000111000110101111111100101110101010001 +10000010010010010010011010001101110111000000100011011 +10111010100110110000000011111110110110010010111111011 +10111010011010100000100100110011110110111111010010011 +10111010011110011100011001101010110111011111000001110 +10000010011100101100000011011100111101100001001101011 +11111110110101100110111010110101100110100011001011010 +9 5 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL +11111110010011000101111010100110001110111010001111111 +10000010110000011000011111000100101111100111001000001 +10111010100010001011000001001110100100011001001011101 +10111010110110000001011100101001101110111110101011101 +10111010011000011100101011111111000110010110001011101 +10000010000010010101001010001000010110100010001000001 +11111110101010101010101010101010101010101010101111111 +00000000110010111000110110001100000100001100100000000 +10000010101101010101010111111111001010100101111001110 +01110001000000010000110110110010011011111011111111010 +01011110000001110001001000000100101111000000001110100 +01001101111001100010110111011011111101000011011001011 +01001010010100100111011111000011101010101101000011000 +00110101111100111111111000100110000111001110010101001 +00110010111010110101111110100010101011111000001111000 +01010000010001111111101111101100011011110110101001110 +10101011100111000110110100010000011111000001101110010 +00110100101101101110111110011110110101000111010010000 +01000011011011011110010101011100000110001100001010101 +11000001100011111000001111000010001100011010001100000 +00101111010101101001000110100101000111000100100011111 +00111100110000010001101011101110111101110010111101101 +11110011011010111111010010000110011011101100111000000 +01001001011101101110000110000010100101111100101101000 +10111111101011010110101111111010111010001111111111011 +01001000110011000000101010001110110011001111100011001 +11111010101011011111110010101000110110000001101010010 +01011000101101010011110110001011011000110100100011111 +10111111111100011111100111111011010111100100111111100 +01111100001100101000000111110011010101011111000010001 +10100110000010101100000110100001010000111000001010001 +00010000100010100111110100100011100000000111101101000 +10110011011111011011000011011001001110100110100100100 +11110100001101001110111100101110001000111110101110000 +01001011101011111001111011011011001011101101100110000 +10000100000001111100010001010010110100011000101011001 +11111110000001110001101100100010100111001011111100111 +10011100010000100001001001110011110111011110011110000 +11011111110011011111111000000001101000101101000001000 +00000101100110010101010110011011110100100101000110110 +11011111011100111111000011010011001110100110110000110 +01000001101001111001000101001110010111001010100111010 +11011111110101011110011111000011010110000001001100101 +01100001101111110111010011000101110100001101010111000 +00010010001011100110000111111010000110110110111110100 +00000000101000001111000010001110011101100110100010100 +11111110000010001001001010101001110010100000101010000 +10000010001100001100000110001011101001011110100011000 +10111010011010101100011111111010101010001110111111000 +10111010011011000001000101010010010111011110110011111 +10111010010000010010010111100100111001010001001111111 +10000010001010011010110101101010001011010111111011101 +11111110100100010111001011000100010111010010111010100 +9 6 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL +11111110110011000101111010100110001110111010001111111 +10000010110001111001111110100101001110000111001000001 +10111010101011000010001000000111101101010001001011101 +10111010010110000001011100101001101110111110101011101 +10111010111100111000001111111011100010110010001011101 +10000010001110011001000110001100011010101110001000001 +11111110101010101010101010101010101010101010101111111 +00000000010011011001010110001101100101101101000000000 +10011111100100011100011111111110000011101100110010111 +01110001000000010000110110110010011011111011111111010 +01111010100101010101101100100000001011100100101010000 +01000001110101101110111011010111110001001111010101010 +01001010010100100111011111000011101010101101000011000 +01010100011101011110011001000111100110101111110100101 +01111011110011111100110111101011100010110001000110001 +01010000010001111111101111101100011011110110101001110 +10001111000011100010010000110100111011100101001010110 +00111000100001100010110010010010111001001011011110001 +01000011011011011110010101011100000110001100001010101 +10100000000010011001101110100011101101111011101101100 +01100110011100100000001111101100001110001101101010110 +00111100110000010001101011101110111101110010111101101 +11010111111110011011110110100010111111001000011100100 +01000101010001100010001010001110101001110000100001001 +10111111101011010110101111111010111010001111111111011 +00101000110010100001001010001111010010101110100010101 +10111010100010010110111010101001111111001000101011011 +01011000101101010011110110001011011000110100100011111 +10011111111000111011000011111111110011000000111111000 +01110000000000100100001011111111011001010011001110000 +10100110000010101100000110100001010000111000001010001 +01110001000011000110010101000010000001100110001100100 +11111010010110010010001010010000000111101111101101101 +11110100001101001110111100101110001000111110101110000 +01101111001111011101011111111111101111001001000010100 +10001000001101110000011101011110111000010100100111000 +11111110000001110001101100100010100111001011111100111 +11111101110001000000101000010010010110111111111111100 +10010110111010010110110001001000100001100100001000001 +00000101100110010101010110011011110100100101000110110 +11111011111000011011100111110111101010000010010100010 +01001101100101110101001001000010011011000110101011011 +11011111110101011110011111000011010110000001001100101 +01100000001110010110110010100100010101101100110110100 +00010011000010101111001111111011001111111111111111101 +00000000101000001111000010001110011101100110100010100 +11111110100110101101101110101101010110000100101010100 +10000010100000000000001010001111100101010010100011001 +10111010111010101100011111111010101010001110111111000 +10111010111010100000100100110011110110111111010010011 +10111010011001011011011110101101110000011000000110110 +10000010001010011010110101101010001011010111111011101 +11111110100000110011101111100000110011110110011110000 +9 7 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL +11111110000110010000101111110011011011101110001111111 +10000010001110000110000001011010110001111011001000001 +10111010011110010111011101010010111000000101001011101 +10111010001001111110100011010110010001000010101011101 +10111010001001101101011011111110110111100110001011101 +10000010110001100110111010001011100101010010001000001 +11111110101010101010101010101010101010101010101111111 +00000000001100100110101010001010011010010010100000000 +10010110110001001001001011111011010110111001110100000 +10001100111111101111001001001101100100000100000000101 +00101111110000000000111001110101011110110001111111010 +10111100001010010001000100101000001110110000101010101 +00011111000001110010001010010110111111111000010110010 +10101001100010100001100110111000011001010000001011010 +00101110100110101001100010111110110111100100010011011 +10101101101110000000010000010011100100001001010110001 +11011010010110110111000101100001101110110000011111100 +11000101011110011101001101101101000110110100100001110 +00010110001110001011000000001001010011011001011111111 +01011101111101100110010001011100010010000100010010011 +00110011001001110101011010111001011011011000111111100 +11000001001111101110010100010001000010001101000010010 +10000010101011001110100011110111101010011101001001110 +10111000101110011101110101110001010110001111011110110 +11101111111110000011111011111111101111011010111110001 +11011000101101011110110110001000101101010001100011010 +11101010110111000011101110101100101010011101101010001 +10101000110010101100001010001100100111001011100010000 +11001111101101101110010111111010100110010101111110010 +10001101111111011011110100000000100110101100110001111 +11110011010111111001010011110100000101101101011111011 +10001100111100111001101010111101111110011001110011011 +10101111000011000111011111000101010010111010111000111 +00001001110010110001000011010001110111000001010001111 +00111010011010001000001010101010111010011100010111110 +01110101110010001111100010100001000111101011011000111 +10101011010100100100111001110111110010011110101001101 +00000000001110111111010111101101101001000000000000011 +11000011101111000011100100011101110100110001011101011 +11111000011001101010101001100100001011011010111001001 +10101110101101001110110010100010111111010111000001000 +10110000011010001010110110111101100100111001010100100 +11011110100000001011001010010110000011010100011001111 +01100001110001101001001101011011101010010011001001011 +00010010010111111010011011111110011010101010111110111 +00000000110111110000111110001001100010011001100011011 +11111110010011111000111010101000000011010001101011110 +10000010111111111111110110001000011010101101100010110 +10111010001111111001001011111111111111011011111110010 +10111010100101011111011011001100001001000000101101100 +10111010001100001110001011111000100101001101010011100 +10000010010101100101001010010101110100101000000100010 +11111110110101100110111010110101100110100011001011010 +10 -1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-padding- +111111101110011001111110101001111100001111101011001111111 +100000100110000110111000101000010011101100101001001000001 +101110100010101010111010000101111000001100101111001011101 +101110101110111000101010100011000110010010100001001011101 +101110101001000001111001111111100101001011111001001011101 +100000101101101001100101001000101011110101011010001000001 +111111101010101010101010101010101010101010101010101111111 +000000001101101101011001011000111111101001010000100000000 +100010111011010111110001011111101011111001101111011111001 +000001011110111011010010101001101000001101101000111001110 +000000110001101001011000001000000000110001100101011110010 +100010001101101101010010110101011110111011001010001000010 +011111111010100011101000000101001100101100001110011101001 +101100001000110101000001111000111001101001101101101100110 +001010111000011111001101110110110101010110001010100111010 +110000001001111011111100110001000101110100101000110001010 +111110101011010000011010110100001101100101001011101000001 +101010011011101101010011101101101001001001100001101001111 +011100100001100011101101110010110001101011010100111011010 +111101000110110111000011100001111011101011100011011000011 +110101100110001001000110010100101000111100101100001100111 +111101010010011011111001110010101101101011111000101101000 +101111100011011100010110100010110101010010000001100110100 +111000011010011010000100110011011000011101011001100110011 +011101100111011010110001001101111101100100001100100101011 +100010001111101011100011101111110101001101111101101110110 +111011111010001011000100001111110101001011110110111110110 +001110001010111111001011111000111111100101010000100010010 +100110101010010101001100011010101111111100001110101011001 +011010001010100101110001101000111001101001111101100010110 +111011111001000000011010111111100001000101100000111111110 +011000010100000001101000101111111010100011011100011011011 +001110101111001000001000000010101100100101011100011111011 +000001010011111001010101110100101000101111111001101001101 +110100100011001101010010011110001001000110001001000011111 +110110011010100111100011101111000111101001001010111000000 +011000100001010001111011010111001111111100001010010011011 +010100001011010101011011111101111100001111111100101101111 +111110101001111011000100011011000111101111111100110100010 +001010000101011011100101110000101100101011010011100110001 +011101100110011110001010000110101100101100101010101110111 +111010010001111101011001111101011101101101111001111101100 +111100110010011011011010011110000101010011010001110011000 +100000010111001100110101100010111000011101111000001010010 +011011101110001000011000011010011011110101101010110011000 +001001011011011011011001111001101100011001100100110100100 +101001100011000101001011110101010101011010111110010111110 +111110000001101011011100000011111011101101010011100110011 +000000100100101001101010011111101001110101001000111110011 +000000001111010111111101101000100000001001100100100011000 +111111101000110000110010101010111001100011111100101010010 +100000100111000010000001001000101010101000011111100010011 +101110101010000100101100001111101000111000111111111110100 +101110100000011101011111110111000000001111100101001000011 +101110100010001101101011001110010001100100010111000011000 +100000100111111000110010000000010011101001001111110101000 +111111101101110101100111001011001001101101101100110101001 +10 0 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-padding- +111111100100001011101100111011101110011101111011001111111 +100000101011101011010101000101111110000001000101001000001 +101110100111000111010111101000010101100001000011001011101 +101110100100101010111000110001010100000000110001001011101 +101110101011010011101011101111110111011001101001001011101 +100000100000000100001000101000100110011000110110001000001 +111111101010101010101010101010101010101010101010101111111 +000000000111111111001011001000101101111011000010100000000 +101010100001000101100011001111111001101011111101000010010 +101100010011010110111111000100000101100000000101010100011 +101101111100000100110101100101101101011100001000110011111 +110000011111111111000000100111001100101001011000011010000 +001101101000110001111010010111011110111110011100001111011 +000001000101011000101100010101010100000100000000000001011 +100111110101110010100000011011011000111011100111001010111 +100010011011101001101110100011010111100110111010100011000 +101100111001000010001000100110011111110111011001111010011 +000111010110000000111110000000000100100100001100000100010 +110001101100001110000000011111011100000110111001010110111 +101111010100100101010001110011101001111001110001001010001 +100111110100011011010100000110111010101110111110011110101 +010000011111110110010100011111000000000110010101000000101 +000010101110110001111011001111011000111111101100001011001 +101010001000001000010110100001001010001111001011110100001 +001111110101001000100011011111101111110110011110110111001 +001111000010000110001110000010011000100000010000000011011 +010111111111100110101001101111111000100110011011111111011 +011110001000101101011001101000101101110111000010100010000 +110110101000000111011110001010111101101110011100101011011 +110110001111001000011100001000110100000100010000100011011 +010111111100101101110111011111101100101000001101111110011 +001010000110010011111010111101101000110001001110001001001 +011100111101011010011010010000111110110111001110001101001 +101100011110010100111000011001000101000010010100000100000 +011001101110100000111111110011100100101011100100101110010 +100100001000110101110001111101010101111011011000101010010 +001010110011000011101001000101011101101110011000000001001 +111001000110111000110110010000010001100010010001000000010 +010011100100010110101001110110101010000010010001011001111 +011000010111001001110111100010111110111001000001110100011 +001111110100001100011000010100111110111110111000111100101 +010111011100010000110100010000110000000000010100010000001 +010001111111110110110111110011101000111110111100011110101 +110010000101011110100111110000101010001111101010011000000 +001001111100011010001010001000001001100111111000100001010 +100100010110110110110100010100000001110100001001011001001 +101001101110101000100110011000111000110111010011111010011 +111110010011111001001110010001101001111111000001110100001 +000000110110111011111000001111111011100111011010111110001 +000000001010111010010000001000101101100100001001100010101 +111111100101011101011111001010110100001110010001101011111 +100000100101010000010011011000111000111010001101100010001 +101110101000010110111110011111111010101010101101111110110 +101110100101110000110010011010101101100010001000100101110 +101110101111100000000110100011111100001001111010101110101 +100000100101101010100000010010000001111011011101100111010 +111111101111100111110101011001011011111111111110100111011 +10 1 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-padding- +111111101001011110111001101110111011001000101111001111111 +100000100110111110000000010000101011010100010001001000001 +101110101010010010000010111101000000110100010111001011101 +101110100001111111101101100100000001010101100101001011101 +101110100110000110111110111111100010001100111101001011101 +100000101101010001011101111000110011001101100010001000001 +111111101010101010101010101010101010101010101010101111111 +000000000010101010011110011000111000101110010111100000000 +101000110100010000110110011111101100111110101000000100101 +111001000110000011101010010001010000110101010000000001001 +111000101001010001100000110000111000001001011101100110101 +100101001010101010010101110010011001111100001101001111010 +011000111101100100101111000010001011101011001001011010001 +010100010000001101111001000000000001010001010101010100001 +110010100000100111110101001110001101101110110010011111101 +110111001110111100111011110110000010110011101111110110010 +111001101100010111011101110011001010100010001100101111001 +010010000011010101101011010101010001110001011001010001000 +100100111001011011010101001010001001010011101100000011101 +111010000001110000000100100110111100101100100100011111011 +110010100001001110000001010011101111111011101011001011111 +000101001010100011000001001010010101010011000000010101111 +010111111011100100101110011010001101101010111001011110011 +111111011101011101000011110100011111011010011110100001011 +011010100000011101110110001010111010100011001011100010011 +011010010111010011011011010111001101110101000101010110001 +000011111010110011111100111111101101110011001110111110001 +001010001101111000001100111000111000100010010111100011010 +100010101101010010001011011010101000111011001001101010001 +100010001010011101001001011000100001010001000101100010001 +000011111001111000100010001111111001111101011000111111001 +011111010011000110101111101000111101100100011011011100011 +001001101000001111001111000101101011100010011011011000011 +111001001011000001101101001100010000010111000001010001010 +001100111011110101101010100110110001111110110001111011000 +110001011101100000100100101000000000101110001101111111000 +011111100110010110111100010000001000111011001101010100011 +101100010011101101100011000101000100110111000100010101000 +000110110001000011111100100011111111010111000100001100101 +001101000010011100100010110111101011101100010100100001001 +011010100001011001001101000001101011101011101101101001111 +000010001001000101100001000101100101010101000001000101011 +000100101010100011100010100110111101101011101001001011111 +100111010000001011110010100101111111011010111111001101010 +011100101001001111011111011101011100110010101101110100000 +110001000011100011100001000001010100100001011100001100011 +101001111011111101110011001101101101100010000110101111001 +111110000110101100011011000100111100101010010100100001011 +000000100011101110101101011111101110110010001111111111011 +000000001111101111000101011000111000110001011100100011111 +111111101000001000001010011010100001011011000100101010101 +100000100000000101000110001000101101101111011000100011011 +101110100101000011101011001111101111111111111000111111100 +101110100000100101100111001111111000110111011101110000100 +101110101010110101010011110110101001011100101111111011111 +100000100000111111110101000111010100101110001000110010000 +111111101010110010100000001100001110101010101011110010001 +10 2 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-padding- +111111100010000101100010110101100000010011110111001111111 +100000100010011010100100110100001111110000110101001000001 +101110101001001001011001100110011011101111001111001011101 +101110101101011011001001000000100101110001000001001011101 +101110101101011101100101101111111001010111100101001011101 +100000101001110101111001011000110111101001000110001000001 +111111101010101010101010101010101010101010101010101111111 +000000001110001110111010111000111100001010110011000000000 +101111100111001011101101001111110111100101110011001111100 +011101000010100111001110110101110100010001110100100101101 +100011110010001010111011101011100011010010000110111101110 +000001001110001110110001010110111101011000101001101011110 +000011100110111111110100011001010000110000010010000001010 +110000010100101001011101100100100101110101110001110000101 +101001111011111100101110010101010110110101101001000100110 +010011001010011000011111010010100110010111001011010010110 +100010110111001100000110101000010001111001010111110100010 +110110000111110001001111110001110101010101111101110101100 +111111100010000000001110010001010010001000110111011000110 +011110000101010100100000000010011000001000000000111011111 +101001111010010101011010001000110100100000110000010000100 +100001001110000111100101101110110001110111100100110001011 +001100100000111111110101000001010110110001100010000101000 +011011011001111001100111010000111011111110111010000101111 +000001111011000110101101010001100001111000010000111001000 +111110010011110111111111110011101001010001100001110010101 +011011111001101000100111101111110110101000010101111111010 +101110001001011100101000011000111100000110110011100011110 +111010101110001001010000001010110011100000010010101011010 +000110001110111001101101111000100101110101100001100010101 +011011111010100011111001011111100010100110000011111110010 +111011010111100010001011001100011001000000111111111000111 +010010110011010100010100011110110000111001000000000011000 +011101001111100101001001101000110100110011100101110101110 +010111100000101110110001111101101010100101101010100000011 +010101011001000100000000001100100100001010101001011011100 +000100111101001101100111001011010011100000010110001111000 +001000010111001001000111100001100000010011100000110001100 +011101101010011000100111111000100100001100011111010111110 +101001000110111000000110010011001111001000110000000101101 +000001111010000010010110011010110000110000110110110010100 +100110001101100001000101100001000001110001100101100001111 +011111110001111000111001111101100110110000110010010000100 +000011010100101111010110000001011011111110011011101001110 +000111110010010100000100000110000111101001110110101111011 +010101000111000111000101100101110000000101111000101000111 +101001100000100110101000010110110110111001011101110100010 +111110000010001000111111100000011000001110110000000101111 +000000111000110101110110001111110101101001010100111110000 +000000001011001011100001111000111100010101111000100011011 +111111100011010011010001001010111010000000011111101011110 +100000101100100001100010101000101001001011111100100011111 +101110101110011000110000011111110100100100100011111110111 +101110101100000001000011101011011100010011111001010100000 +101110101001101110001000101101110010000111110100100000100 +100000100100011011010001100011110000001010101100010110100 +111111101001101001111011010111010101110001110000101001010 +10 3 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-padding- +111111101010000101100010110101100000010011110111001111111 +100000101111110111001001011001100010011101011001001000001 +101110100111111111101111010000101101011001111011001011101 +101110101101011011001001000000100101110001000001001011101 +101110100000110000001000001111110100111010001001001011101 +100000100111000011001111101000100001011111110010001000001 +111111101010101010101010101010101010101010101010101111111 +000000001011100011010111011000110001100111011110100000000 +101101110001111101011011111111100001010011000101101001011 +011101000010100111001110110101110100010001110100100101101 +001110111111100111010110000110001110111111101011010000011 +110111011000111000000111100000001011101110011111011101000 +000011100110111111110100011001010000110000010010000001010 +011101011001000100110000001001001000011000011100011101000 +011111101101001010011000100011100000000011011111110010000 +010011001010011000011111010010100110010111001011010010110 +001111111010100001101011000101111100010100111010011001111 +000000010001000111111001000111000011100011001011000011010 +111111100010000000001110010001010010001000110111011000110 +110011001000111001001101101111110101100101101101010110010 +011111101100100011101100111110000010010110000110100110010 +100001001110000111100101101110110001110111100100110001011 +100001101101010010011000101100111011011100001111101000101 +101101001111001111010001100110001101001000001100110011001 +000001111011000110101101010001100001111000010000111001000 +010011011110011010010010011110000100111100001100011111000 +101111111111011110010001011111100000011110100011111111100 +101110001001011100101000011000111100000110110011100011110 +010110101011100100111101101010111110001101111111101010111 +110010001000001111011011001000110011000011010111100010011 +011011111010100011111001011111100010100110000011111110010 +010110011010001111100110100001110100101101010010010101010 +100100100101100010100010101000000110001111110110110101110 +011101001111100101001001101000110100110011100101110101110 +111010101101000011011100010000000111001000000111001101110 +100011001111110010110110111010010010111100011111101101010 +000100111101001101100111001011010011100000010110001111000 +100101011010100100101010001100001101111110001101011100001 +101011111100101110010001001110010010111010101001100001000 +101001000110111000000110010011001111001000110000000101101 +101100110111101111111011110111011101011101011011011111001 +010000011011010111110011010111110111000111010011010111001 +011111110001111000111001111101100110110000110010010000100 +101110011001000010111011101100110110010011110110000100011 +110001100100100010110010110000110001011111000000011001101 +010101000111000111000101100101110000000101111000101000111 +101001101101001011000101111011011011010100110000011001111 +111110010100111110001001010110101110111000000110110011001 +000000111000110101110110001111110101101001010100111110000 +000000001110100110001100011000110001111000010101100010110 +111111101101100101100111111010101100110110101001101011000 +100000101100100001100010101000101001001011111100100011111 +101110100011110101011101111111111001001001001110111111010 +101110101010110111110101011101101010100101001111100010110 +101110101001101110001000101101110010000111110100100000100 +100000100001110110111100001110011101100111000001111011001 +111111101111011111001101100001100011000111000110011111100 +10 4 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-padding- +111111101110011001111110101001111100001111101011001111111 +100000100110000110111000101000010011101100101001001000001 +101110100010101010111010000101111000001100101111001011101 +101110101110111000101010100011000110010010100001001011101 +101110101001000001111001111111100101001011111001001011101 +100000101101101001100101001000101011110101011010001000001 +111111101010101010101010101010101010101010101010101111111 +000000001101101101011001011000111111101001010000100000000 +100010111011010111110001011111101011111001101111011111001 +000001011110111011010010101001101000001101101000111001110 +000000110001101001011000001000000000110001100101011110010 +100010001101101101010010110101011110111011001010001000010 +011111111010100011101000000101001100101100001110011101001 +101100001000110101000001111000111001101001101101101100110 +001010111000011111001101110110110101010110001010100111010 +110000001001111011111100110001000101110100101000110001010 +111110101011010000011010110100001101100101001011101000001 +101010011011101101010011101101101001001001100001101001111 +011100100001100011101101110010110001101011010100111011010 +111101000110110111000011100001111011101011100011011000011 +110101100110001001000110010100101000111100101100001100111 +111101010010011011111001110010101101101011111000101101000 +101111100011011100010110100010110101010010000001100110100 +111000011010011010000100110011011000011101011001100110011 +011101100111011010110001001101111101100100001100100101011 +100010001111101011100011101111110101001101111101101110110 +111011111010001011000100001111110101001011110110111110110 +001110001010111111001011111000111111100101010000100010010 +100110101010010101001100011010101111111100001110101011001 +011010001010100101110001101000111001101001111101100010110 +111011111001000000011010111111100001000101100000111111110 +011000010100000001101000101111111010100011011100011011011 +001110101111001000001000000010101100100101011100011111011 +000001010011111001010101110100101000101111111001101001101 +110100100011001101010010011110001001000110001001000011111 +110110011010100111100011101111000111101001001010111000000 +011000100001010001111011010111001111111100001010010011011 +010100001011010101011011111101111100001111111100101101111 +111110101001111011000100011011000111101111111100110100010 +001010000101011011100101110000101100101011010011100110001 +011101100110011110001010000110101100101100101010101110111 +111010010001111101011001111101011101101101111001111101100 +111100110010011011011010011110000101010011010001110011000 +100000010111001100110101100010111000011101111000001010010 +011011101110001000011000011010011011110101101010110011000 +001001011011011011011001111001101100011001100100110100100 +101001100011000101001011110101010101011010111110010111110 +111110000001101011011100000011111011101101010011100110011 +000000100100101001101010011111101001110101001000111110011 +000000001111010111111101101000100000001001100100100011000 +111111101000110000110010101010111001100011111100101010010 +100000100111000010000001001000101010101000011111100010011 +101110101010000100101100001111101000111000111111111110100 +101110100000011101011111110111000000001111100101001000011 +101110100010001101101011001110010001100100010111000011000 +100000100111111000110010000000010011101001001111110101000 +111111101101110101100111001011001001101101101100110101001 +10 5 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-padding- +111111100001011110111001101110111011001000101111001111111 +100000101110011110100000110000001011110100110001001000001 +101110101001001001011001100110011011101111001111001011101 +101110101011010101000111001110101011111111001101001011101 +101110100101011101100101101111111001010111100101001011101 +100000100101110001111101011000110011101101000010001000001 +111111101010101010101010101010101010101010101010101111111 +000000001010001010111110111000111000001110110111000000000 +100000101111001011101101001111110111100101110011011001110 +010011001100101001000000111011111010011111111010101011100 +100011110010001010111011101011100011010010000110111101110 +000101001010001010110101010010111001011100101101101111110 +011000111101100100101111000010001011101011001001011010001 +110100010000101101011001100000100001110001110101110100101 +101001111011111100101110010101010110110101101001000100110 +011101000100010110010001011100101000011001000101011100111 +100010110111001100000110101000010001111001010111110100010 +110010000011110101001011110101110001010001111001110001100 +100100111001011011010101001010001001010011101100000011101 +011010000001010000100100000110011100001100000100111111111 +101001111010010101011010001000110100100000110000010000100 +101111000000001001101011100000111111111001101010111111010 +001100100000111111110101000001010110110001100010000101000 +011111011101111101100011010100111111111010111110000001111 +011010100000011101110110001010111010100011001011100010011 +111010010111110011111011110111101101010101100101110110101 +011011111001101000100111101111110110101000010101111111010 +100010001111010010100110011000110010001000111101100011111 +111010101110001001010000001010110011100000010010101011010 +000010001010111101101001111000100001110001100101100010101 +000011111001111000100010001111111001111101011000111111001 +111111010011100110001111001000011101000100111011111100111 +010010110011010100010100011110110000111001000000000011000 +010011000001101011000111100110111010111101101011111011111 +010111100000101110110001111101101010100101101010100000011 +010001011101000000000100001000100000001110101101011111100 +011111100110010110111100010000001000111011001101010100011 +001100010011001101000011100101100100010111100100110101100 +011101101010011000100111111000100100001100011111010111110 +100111001000110110001000011101000001000110111110001011100 +000001111010000010010110011010110000110000110110110010100 +100010001001100101000001100101000101110101100001100101111 +000100101010100011100010100110111101101011101001001011111 +000111010000101011010010000101011111111010011111101101110 +000111110010010100000100000110000111101001110110101111011 +011011001001001001001011101011111110001011110110100110110 +101001100000100110101000010110110110111001011101110100010 +111110000110001100111011100100011100001010110100000001111 +000000100011101110101101011111101110110010001111111111011 +000000001111001111100101111000111000010001111100100011011 +111111100011010011010001001010111010000000011111101011110 +100000100010101111101100101000100111000101110010100011110 +101110100110011000110000011111110100100100100011111110111 +101110100000000101000111101111011000010111111101010000000 +101110100010110101010011110110101001011100101111111011111 +100000100000011111010101100111110100001110101000010010100 +111111101001101001111011010111010101110001110000101001010 +10 6 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-padding- +111111101001011110111001101110111011001000101111001111111 +100000101110000110111000101000010011101100101001001000001 +101110101011011011001011110100001001111101011111001011101 +101110100011010101000111001110101011111111001101001011101 +101110101100010100101100101111110000011110101101001011101 +100000100110110010111110011000110000101110000010001000001 +111111101010101010101010101010101010101010101010101111111 +000000000010010010100110101000100000010110101111000000000 +100111111101011001111111011111100101110111100001010010111 +010011001100101001000000111011111010011111111010101011100 +101010111011000011110010100010101010011011001111110100111 +000110001001001001110110010001111010011111101110101100110 +011000111101100100101111000010001011101011001001011010001 +101100001000110101000001111000111001101001101101101100110 +111011101001101110111100000111000100100111111011010110100 +011101000100010110010001011100101000011001000101011100111 +101011111110000101001111100001011000110000011110111101011 +110001000000110110001000110110110010010010111010110010100 +100100111001011011010101001010001001010011101100000011101 +000010011001001000111100011110000100010100011100100111100 +111011101000000111001000011010100110110010100010000010110 +101111000000001001101011100000111111111001101010111111010 +000101101001110110111100001000011111111000101011001100001 +011100011110111110100000010111111100111001111101000010111 +011010100000011101110110001010111010100011001011100010011 +100010001111101011100011101111110101001101111101101110110 +001011111011111010110101111111100100111010000111111111000 +100010001111010010100110011000110010001000111101100011111 +110010101111000000011001001010111010101001011011101010011 +000010001001111110101010111000100010110010100110100011101 +000011111001111000100010001111111001111101011000111111001 +100111001011111110010111010000000101011100100011100100100 +000000100001000110000110001100100010101011010010010001010 +010011000001101011000111100110111010111101101011111011111 +011110101001100111111000110100100011101100100011101001010 +010010011110000011000111001011100011001101101110011100100 +011111100110010110111100010000001000111011001101010100011 +010100001011010101011011111101111100001111111100101101111 +001111111000001010110101101010110110011110001101000101100 +100111001000110110001000011101000001000110111110001011100 +001000110011001011011111010011111001111001111111111011101 +100001001010100110000010100110000110110110100010100110111 +000100101010100011100010100110111101101011101001001011111 +011111001000110011001010011101000111100010000111110101101 +010101100000000110010110010100010101111011100100111101001 +011011001001001001001011101011111110001011110110100110110 +101001101001101111100001011111111111110000010100111101011 +111110000101001111111000100111011111001001110111000010111 +000000100011101110101101011111101110110010001111111111011 +000000001111010111111101101000100000001001100100100011000 +111111101001000001000011011010101000010010001101101011100 +100000101010101111101100101000100111000101110010100011110 +101110101111010001111001011111111101101101101010111111110 +101110101011000110000100101100011011010100111110010011000 +101110100010110101010011110110101001011100101111111011111 +100000100000000111001101111111101100010110110000001010111 +111111101011111011101001000101000111100011100010111011000 +10 7 otpauth://totp/Bastion:ana%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Bastion&algorithm=SHA1&digits=6&period=30&x=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-padding- +111111100100001011101100111011101110011101111011001111111 +100000100001111001000111010111101100010011010101001000001 +101110100110001110011110100001011100101000001011001011101 +101110100100101010111000110001010100000000110001001011101 +101110100001000001111001111111100101001011111001001011101 +100000101001001101000001101000101111010001111110001000001 +111111101010101010101010101010101010101010101010101111111 +000000000101101101011001011000111111101001010000100000000 +100101101000001100101010001111110000100010110100010100000 +101100010011010110111111000100000101100000000101010100011 +111111101110010110100111110111111111001110011010100001101 +111001010110110110001001101110000101100000010001010011001 +001101101000110001111010010111011110111110011100001111011 +010011010111001010111110000111000110010110010010010011001 +101110111100111011101001010010010001110010101110000011110 +100010011011101001101110100011010111100110111010100011000 +111110101011010000011010110100001101100101001011101000001 +001110011111001001110111001001001101101101000101001101011 +110001101100001110000000011111011100000110111001010110111 +111101000110110111000011100001111011101011100011011000011 +101110111101010010011101001111110011100111110111010111100 +010000011111110110010100011111000000000110010101000000101 +010000111100100011101001011101001010101101111110011001011 +100011000001000001011111101000000011000110000010111101000 +001111110101001000100011011111101111110110011110110111001 +011101010000010100011100010000001010110010000010010001001 +011111111110101111100000101111110001101111010010111110010 +011110001000101101011001101000101101110111000010100010000 +100110101010010101001100011010101111111100001110101011001 +111110001110000001010101001000111101001101011001100010010 +010111111100101101110111011111101100101000001101111110011 +011000010100000001101000101111111010100011011100011011011 +010101110100010011010011011001110111111110000111000100000 +101100011110010100111000011001000101000010010100000100000 +001011111100110010101101100001110110111001110110111100000 +101101000001111100111000110100011100110010010001100011011 +001010110011000011101001000101011101101110011000000001001 +101011010100101010100100000010000011110000000011010010000 +011010101101011111100000111111100011001011011000010000110 +011000010111001001110111100010111110111001000001110100011 +011101100110011110001010000110101100101100101010101110111 +011110010101011001111101011001111001001001011101011001000 +010001111111110110110111110011101000111110111100011110101 +100000010111001100110101100010111000011101111000001010010 +000000110101010011000011000001000000101110110001101000011 +100100010110110110110100010100000001110100001001011001001 +101001111100111010110100001010101010100101000001101000001 +111110011010110000000111011000100000110110001000111101000 +000000110110111011111000001111111011100111011010111110001 +000000001000101000000010011000111111110110011011100010111 +111111100100010100010110001010111101000111011000101010110 +100000101101010000010011011000111000111010001101100010001 +101110100010000100101100001111101000111000111111111110100 +101110101100111001111011010011100100101011000001101100111 +101110100111100000000110100011111100001001111010101110101 +100000100111111000110010000000010011101001001111110101000 +111111101110101110111100010000010010110110110111101110010 diff --git a/scripts/bastion/e2e/LiveE2e.java b/scripts/bastion/e2e/LiveE2e.java new file mode 100644 index 00000000000..057fad074d0 --- /dev/null +++ b/scripts/bastion/e2e/LiveE2e.java @@ -0,0 +1,644 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.spec.ECGenParameterSpec; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/// The end-to-end run against a live Bastion server, over HTTP only. +/// +/// java e2e/LiveE2e.java http://localhost:18090 +/// +/// It does what the app does, the way the app does it, and nothing the +/// server's own tests can reach inside the process: two customers open +/// accounts, one enrols an authenticator and signs in with password and code +/// through the authorization-code flow with PKCE, registers a device key, +/// adds the other as a payee and pays them with a request that key signs -- +/// unsigned and replayed copies refused -- the two trade bitcoin until an +/// order fills, and a withdrawal of coins is refused until the address is on +/// the allow-list and out of its cooling-off. +/// +/// One file and the JDK alone (17 or newer), so it runs anywhere the server +/// does, headless. `run-e2e.sh` starts the server and runs it. +public final class LiveE2e { + private static final String CLIENT_ID = "bastion-app"; + private static final String REDIRECT = "http://127.0.0.1/callback"; + private static final String PASSWORD = "e2e-Correct-Horse-9"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final String base; + private final HttpClient http = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER).build(); + private int checks; + + private LiveE2e(String base) { + this.base = base.endsWith("/") ? base.substring(0, base.length() - 1) : base; + } + + public static void main(String[] args) throws Exception { + if (args.length != 1) { + System.err.println("usage: java LiveE2e.java "); + System.exit(2); + } + LiveE2e run = new LiveE2e(args[0]); + try { + run.all(); + } catch (AssertionError failed) { + System.err.println("[bastion-e2e] FAILED after " + run.checks + " checks: " + + failed.getMessage()); + System.exit(1); + } + System.out.println("[bastion-e2e] passed, " + run.checks + " checks"); + } + + // ------------------------------------------------------------------ flows + + private void all() throws Exception { + Customer alice = new Customer("alice"); + Customer bob = new Customer("bob"); + + step("an authenticator makes the password not enough"); + alice.enrolTotp(); + String pending = alice.password(); + check(pending != null, "the password alone is held back for a code"); + check(alice.code(pending, "000000") == null, "a wrong code signs nobody in"); + String session = alice.code(pending, totp(alice.secret, 30000)); + check(session != null, "the next step's code signs in"); + alice.token = alice.tokens(session); + + step("a device key is registered"); + // Only a sign-in with a second factor adds a device: bob's password + // alone is refused, so he turns on an app and signs in with it. + check(bob.deviceStatus() == 403, "a password-only sign-in adds no device"); + bob.enrolTotp(); + String bobPending = bob.password(); + String bobSession = bob.code(bobPending, totp(bob.secret, 30000)); + check(bobSession != null, "bob signs in with his code"); + bob.token = bob.tokens(bobSession); + alice.registerDevice(); + bob.registerDevice(); + + step("a payment is signed by the device"); + String checking = alice.first("USD", "CHECKING"); + alice.deposit(checking, 500000); + String payee = (String) alice.signed("POST", "/api/payees", + "{\"email\":\"" + bob.email + "\",\"nickname\":\"Bob\"}", null, 200).get("id"); + String pay = "{\"fromAccountId\":\"" + checking + "\",\"toAccountId\":null,\"payeeId\":\"" + + payee + "\",\"amount\":2500,\"memo\":\"lunch\"}"; + String key = id(); + alice.expect("POST", "/api/transfers", pay, key, null, 403); + String signature = alice.signature("POST", "/api/transfers", pay, key); + alice.expect("POST", "/api/transfers", pay, key, signature, 200); + // The Idempotency-Key is signed: the same request under a fresh key + // (and a fresh nonce) is refused, not paid twice. + alice.expect("POST", "/api/transfers", pay, id(), alice.signature("POST", + "/api/transfers", pay, key), 403); + check(alice.balance(checking) == 500000 - 2500, "the payment left Alice's account"); + check(bob.balance(bob.first("USD", "CHECKING")) == 2500, "and reached Bob's"); + + step("an order fills"); + String bobWallet = bob.open("BTC", "WALLET"); + bob.deposit(bobWallet, 10000000L); + String aliceWallet = alice.open("BTC", "WALLET"); + // An order moves value, so it is signed as a payment is. + bob.expect("POST", "/api/exchange/orders", order("SELL", 6000000L, 100000L), id(), null, + 403); + Map sell = bob.signed("POST", "/api/exchange/orders", order("SELL", 6000000L, 100000L), + id(), 200); + check("OPEN".equals(sell.get("status")), "the sell rests on the book: " + sell); + Map buy = alice.signed("POST", "/api/exchange/orders", order("BUY", 6000000L, 100000L), + id(), 200); + check("FILLED".equals(buy.get("status")), "the buy fills against it: " + buy); + check(alice.balance(aliceWallet) == 100000L, "Alice holds the coins she bought"); + check(bob.balance(bobWallet) == 10000000L - 100000L, "Bob no longer does"); + + step("coins leave only to an allow-listed address, after its cooling-off"); + String withdraw = "/api/accounts/" + aliceWallet + "/crypto-withdraw"; + String nowhere = "{\"addressId\":\"nope\",\"amount\":50000}"; + String nowhereKey = id(); + alice.expect("POST", withdraw, nowhere, nowhereKey, + alice.signature("POST", withdraw, nowhere, nowhereKey), 403); + String address = (String) alice.signed("POST", "/api/crypto/addresses", + "{\"asset\":\"BTC\",\"address\":\"bc1qexampleexampleexample0000\",\"label\":\"cold\"}", + null, 200).get("id"); + String there = "{\"addressId\":\"" + address + "\",\"amount\":50000}"; + alice.expect("POST", withdraw, there, id(), null, 403); + String thereKey = id(); + alice.expect("POST", withdraw, there, thereKey, + alice.signature("POST", withdraw, there, thereKey), 409); + check(alice.balance(aliceWallet) == 100000L, "and nothing left the wallet"); + + step("the books balance, and staff reads are on the record"); + String demo = "bastion-demo-password"; + Customer auditor = new Customer("auditor@bastion.example", demo); + Customer admin = new Customer("admin@bastion.example", demo); + Customer admin2 = new Customer("admin2@bastion.example", demo); + check(Boolean.TRUE.equals(auditor.json(auditor.call("GET", "/api/books/trial-balance", + null, null, null), 200).get("balanced")), "debits equal credits in every asset"); + check(Boolean.TRUE.equals(auditor.json(auditor.call("GET", "/api/books/balance-sheet", + null, null, null), 200).get("balanced")), "assets = liabilities + equity"); + check(Boolean.TRUE.equals(auditor.json(auditor.call("GET", "/api/books/reconciliation", + null, null, null), 200).get("clean")), "sub-ledgers reconcile to control"); + Map chain = auditor.json(auditor.call("GET", "/api/audit/chain", null, null, null), 200); + check(Boolean.TRUE.equals(chain.get("intact")), "the audit chain holds: " + chain); + Map evidence = auditor.json(auditor.call("GET", + "/api/audit/evidence/journal?format=csv", null, null, null), 200); + check(String.valueOf(evidence.get("content")).startsWith("entry_id,") + && String.valueOf(evidence.get("sha256")).length() == 64, + "the journal exports as CSV with its digest"); + check(auditor.call("POST", "/api/admin/adjustments", "{\"accountId\":\"" + checking + + "\",\"amount\":1,\"memo\":\"\",\"password\":\"" + demo + "\"}", id(), + null).statusCode() == 403, "an auditor cannot move money"); + check(alice.call("GET", "/api/books/trial-balance", null, null, null).statusCode() == 403, + "a customer cannot read the books"); + check(auditor.call("GET", "/api/audit/access-log", null, null, null).body() + .contains("auditor@bastion.example"), "the auditor's reads are in the access log"); + + step("a large adjustment takes two administrators"); + String adjust = "{\"accountId\":\"" + checking + "\",\"amount\":500000," + + "\"memo\":\"goodwill\",\"password\":\"" + demo + "\"}"; + Map pendingAdj = admin.json(admin.call("POST", "/api/admin/adjustments", adjust, id(), + null), 200); + check("PENDING".equals(pendingAdj.get("status")), "it waits: " + pendingAdj); + String approve = "/api/admin/approvals/" + pendingAdj.get("id") + "/approve"; + String decision = "{\"password\":\"" + demo + "\",\"reason\":\"ok\"}"; + check(admin.call("POST", approve, decision, null, null).statusCode() == 403, + "its maker cannot approve it"); + long before = alice.balance(checking); + check("EXECUTED".equals(admin2.json(admin2.call("POST", approve, decision, null, null), + 200).get("status")), "a second administrator can"); + check(alice.balance(checking) == before + 500000, "and the money moved once"); + + step("chart data"); + List candles = (List) new Json(alice.call("GET", + "/api/exchange/markets/BTC-USD/candles?interval=3600000&limit=200", null, null, + null).body()).value(); + check(candles.size() > 24, "the demo history draws candles: " + candles.size()); + List history = (List) new Json(alice.call("GET", "/api/accounts/" + checking + + "/balance-history?days=30", null, null, null).body()).value(); + check(history.size() == 30, "thirty days of balance history"); + } + + private static String order(String side, long price, long quantity) { + return "{\"market\":\"BTC-USD\",\"side\":\"" + side + "\",\"type\":\"LIMIT\",\"price\":" + + price + ",\"quantity\":" + quantity + ",\"budget\":0}"; + } + + // --------------------------------------------------------------- a person + + private final class Customer { + final String email; + String token; + String secret; + KeyPair key; + String device; + final String pw; + + /// An account the server already has -- the demo staff -- signed in + /// with the password and the demo authenticator's code: staff hold + /// their role only with a second factor. + Customer(String existing, String password) throws Exception { + email = existing; + pw = password; + String pending = password(); + check(pending != null, email + " signs in with the password"); + String session = code(pending, totp(STAFF_TOTP_SECRET, 0)); + check(session != null, email + " gives the authenticator code"); + token = tokens(session); + } + + /// What adding a throwaway key answers with this token. + int deviceStatus() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + return call("POST", "/api/devices", "{\"publicKey\":\"" + + b64(generator.generateKeyPair().getPublic().getEncoded()) + + "\",\"platform\":\"and\",\"rooted\":false,\"attested\":true," + + "\"integrityToken\":\"e2e\",\"password\":\"" + PASSWORD + "\"}", + null, null).statusCode(); + } + + Customer(String name) throws Exception { + pw = PASSWORD; + email = name + "-" + id().substring(0, 10) + "@bastion.example"; + HttpResponse made = send(post("/api/account/register", + "{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + + "\",\"displayName\":\"" + name + "\"}").build()); + check(made.statusCode() == 200, "register " + email + ": " + made.statusCode() + + " " + made.body()); + String session = password(); + check(session != null, email + " signs in with the password"); + token = tokens(session); + } + + /// Signs in with the password form; the session cookie, or null. + String password() throws Exception { + HttpResponse r = send(form("/login", null, "username", email, + "password", pw)); + return r.statusCode() == 200 || r.statusCode() == 302 ? cookie(r, null) : null; + } + + /// Gives the second factor; the signed-in session, or null when refused. + String code(String pending, String code) throws Exception { + HttpResponse r = send(form("/login/mfa", pending, "code", code)); + return r.statusCode() == 200 ? cookie(r, pending) : null; + } + + /// The authorization-code flow with PKCE, from a signed-in session. + String tokens(String session) throws Exception { + String verifier = id() + id(); + String challenge = b64(MessageDigest.getInstance("SHA-256") + .digest(verifier.getBytes(StandardCharsets.US_ASCII))); + HttpResponse authorized = send(HttpRequest.newBuilder(URI.create(base + + "/oauth2/authorize?response_type=code&client_id=" + CLIENT_ID + + "&redirect_uri=" + enc(REDIRECT) + "&scope=openid%20profile&state=s" + + "&nonce=n&code_challenge=" + challenge + "&code_challenge_method=S256")) + .header("Cookie", "CN1SESSION=" + session).GET().build()); + String location = authorized.headers().firstValue("Location").orElse(""); + int at = location.indexOf("code="); + check(location.startsWith(REDIRECT) && at > 0, "an authorization code: " + + authorized.statusCode() + " " + location); + int end = location.indexOf('&', at); + String code = location.substring(at + 5, end < 0 ? location.length() : end); + Map answer = json(send(form("/oauth2/token", null, "grant_type", "authorization_code", + "code", java.net.URLDecoder.decode(code, StandardCharsets.UTF_8), + "redirect_uri", REDIRECT, "code_verifier", verifier, "client_id", + CLIENT_ID)), 200); + check(answer.get("access_token") instanceof String, "an access token"); + return (String) answer.get("access_token"); + } + + void enrolTotp() throws Exception { + // Password-only sign-in: the password goes with both calls. + check(call("POST", "/api/account/mfa/totp", "{}", null, null).statusCode() == 403, + "a password-only token alone cannot add an authenticator app"); + String pw = "{\"password\":\"" + PASSWORD + "\"}"; + secret = (String) json(call("POST", "/api/account/mfa/totp", pw, null, null), 200) + .get("secret"); + Map confirmed = json(call("POST", "/api/account/mfa/totp/confirm", + "{\"code\":\"" + totp(secret, 0) + "\",\"password\":\"" + PASSWORD + + "\"}", null, null), 200); + check(((List) confirmed.get("codes")).size() == 10, "ten recovery codes"); + } + + void registerDevice() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + key = generator.generateKeyPair(); + Map d = json(call("POST", "/api/devices", "{\"publicKey\":\"" + + b64(key.getPublic().getEncoded()) + "\",\"platform\":\"and\"," + + "\"rooted\":false,\"attested\":true,\"integrityToken\":\"e2e\"," + + "\"password\":\"" + PASSWORD + "\"}", null, null), 200); + check(Boolean.TRUE.equals(d.get("trusted")), "the device is trusted: " + d); + device = (String) d.get("id"); + } + + String signature(String method, String target, String body, String idempotencyKey) + throws Exception { + long ts = System.currentTimeMillis(); + String nonce = id(); + String text = method + "|" + target + "|" + hex(MessageDigest.getInstance("SHA-256") + .digest(body.getBytes(StandardCharsets.UTF_8))) + "|" + + (idempotencyKey == null ? "" : idempotencyKey) + "|" + nonce + "|" + ts; + Signature signer = Signature.getInstance("SHA256withECDSA"); + signer.initSign(key.getPrivate()); + signer.update(text.getBytes(StandardCharsets.UTF_8)); + return "v1;device=" + device + ";ts=" + ts + ";nonce=" + nonce + ";sig=" + + b64(jose(signer.sign())); + } + + Map signed(String method, String path, String body, String key, int status) + throws Exception { + return json(call(method, path, body, key, signature(method, path, body, key)), status); + } + + void expect(String method, String path, String body, String key, String signature, + int status) throws Exception { + json(call(method, path, body, key, signature), status); + } + + HttpResponse call(String method, String path, String body, String key, + String signature) throws Exception { + HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(base + path)) + .header("Authorization", "Bearer " + token); + if (key != null) { + b.header("Idempotency-Key", key); + } + if (signature != null) { + b.header("Bastion-Signature", signature); + } + if (body == null) { + b.method(method, HttpRequest.BodyPublishers.noBody()); + } else { + b.header("Content-Type", "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(body)); + } + return send(b.build()); + } + + Map json(HttpResponse r, int status) { + check(r.statusCode() == status, r.request().method() + " " + r.request().uri().getPath() + + " answered " + r.statusCode() + ", expected " + status + ": " + r.body()); + String body = r.body().trim(); + // A refusal answers its reason as text, an answer as JSON. + Object parsed = body.startsWith("{") || body.startsWith("[") ? new Json(body).value() + : null; + return parsed instanceof Map ? (Map) parsed : new LinkedHashMap(); + } + + List accounts() throws Exception { + HttpResponse r = call("GET", "/api/accounts", null, null, null); + check(r.statusCode() == 200, "accounts: " + r.statusCode()); + return (List) new Json(r.body()).value(); + } + + String first(String asset, String kind) throws Exception { + for (Object a : accounts()) { + Map m = (Map) a; + if (asset.equals(m.get("asset")) && kind.equals(m.get("kind"))) { + return (String) m.get("id"); + } + } + throw new AssertionError(email + " has no " + asset + " " + kind); + } + + long balance(String account) throws Exception { + for (Object a : accounts()) { + Map m = (Map) a; + if (account.equals(m.get("id"))) { + return ((Number) m.get("balance")).longValue(); + } + } + throw new AssertionError("no account " + account); + } + + String open(String asset, String kind) throws Exception { + return (String) json(call("POST", "/api/accounts", "{\"asset\":\"" + asset + + "\",\"kind\":\"" + kind + "\",\"name\":\"e2e\"}", null, null), 200).get("id"); + } + + void deposit(String account, long amount) throws Exception { + json(call("POST", "/api/accounts/" + account + "/deposit", + "{\"amount\":" + amount + ",\"memo\":\"e2e\"}", id(), null), 200); + } + } + + // ---------------------------------------------------------------- helpers + + private void step(String what) { + System.out.println("[bastion-e2e] " + what); + } + + private void check(boolean ok, String what) { + checks++; + if (!ok) { + throw new AssertionError(what); + } + } + + private HttpRequest.Builder post(String path, String json) { + return HttpRequest.newBuilder(URI.create(base + path)).header("Content-Type", + "application/json").POST(HttpRequest.BodyPublishers.ofString(json)); + } + + private HttpRequest form(String path, String session, String... pairs) { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < pairs.length; i += 2) { + if (body.length() > 0) { + body.append('&'); + } + body.append(enc(pairs[i])).append('=').append(enc(pairs[i + 1])); + } + HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(base + path)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body.toString())); + if (session != null) { + b.header("Cookie", "CN1SESSION=" + session); + } + return b.build(); + } + + private HttpResponse send(HttpRequest request) throws Exception { + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } + + /// The CN1SESSION the response sets, or `fallback` when it sets none. + private static String cookie(HttpResponse r, String fallback) { + for (String set : r.headers().allValues("Set-Cookie")) { + if (set.startsWith("CN1SESSION=")) { + int end = set.indexOf(';'); + return set.substring(11, end < 0 ? set.length() : end); + } + } + return fallback; + } + + private static String enc(String s) { + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } + + private static String b64(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } + + private static String id() { + byte[] b = new byte[16]; + RANDOM.nextBytes(b); + return hex(b); + } + + private static String hex(byte[] bytes) { + StringBuilder s = new StringBuilder(); + for (byte b : bytes) { + s.append(Character.forDigit((b >> 4) & 15, 16)).append(Character.forDigit(b & 15, 16)); + } + return s.toString(); + } + + /// RFC 6238, 30-second steps, six digits, HMAC-SHA1 -- what an + /// authenticator app computes from the enrolment's secret. + /// DemoAccounts.STAFF_TOTP_SECRET: the demo staff's authenticator. + private static final String STAFF_TOTP_SECRET = "BASTIONDEMOSTAFFAUTHENTICATOR234"; + + private static String totp(String base32, long offsetMillis) throws Exception { + long step = (System.currentTimeMillis() + offsetMillis) / 30000L; + byte[] counter = new byte[8]; + for (int i = 7; i >= 0; i--) { + counter[i] = (byte) step; + step >>>= 8; + } + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(base32(base32), "HmacSHA1")); + byte[] h = mac.doFinal(counter); + int o = h[h.length - 1] & 15; + int bin = ((h[o] & 0x7f) << 24) | ((h[o + 1] & 0xff) << 16) | ((h[o + 2] & 0xff) << 8) + | (h[o + 3] & 0xff); + return String.format("%06d", bin % 1000000); + } + + private static byte[] base32(String text) { + String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + int buffer = 0; + int bits = 0; + for (char c : text.toUpperCase(java.util.Locale.ROOT).toCharArray()) { + int v = alphabet.indexOf(c); + if (v < 0) { + continue; + } + buffer = (buffer << 5) | v; + bits += 5; + if (bits >= 8) { + out.write((buffer >> (bits - 8)) & 0xff); + bits -= 8; + } + } + return out.toByteArray(); + } + + /// An ASN.1 DER ECDSA signature as JOSE wants it: r and s, 32 bytes each. + private static byte[] jose(byte[] der) { + int at = 2 + ((der[1] & 0x80) != 0 ? (der[1] & 0x7f) : 0); + byte[] out = new byte[64]; + for (int part = 0; part < 2; part++) { + int len = der[at + 1]; + int start = at + 2; + int skip = Math.max(0, len - 32); + int copy = len - skip; + System.arraycopy(der, start + skip, out, part * 32 + 32 - copy, copy); + at = start + len; + } + return out; + } + + /// Just enough JSON for the server's answers. + private static final class Json { + private final String s; + private int i; + + Json(String s) { + this.s = s; + } + + Object value() { + ws(); + char c = s.charAt(i); + if (c == '{') { + Map m = new LinkedHashMap(); + i++; + ws(); + if (s.charAt(i) == '}') { + i++; + return m; + } + while (true) { + ws(); + String k = (String) value(); + ws(); + i++; + m.put(k, value()); + ws(); + if (s.charAt(i++) == '}') { + return m; + } + } + } + if (c == '[') { + List l = new ArrayList(); + i++; + ws(); + if (s.charAt(i) == ']') { + i++; + return l; + } + while (true) { + l.add(value()); + ws(); + if (s.charAt(i++) == ']') { + return l; + } + } + } + if (c == '"') { + StringBuilder b = new StringBuilder(); + i++; + while (s.charAt(i) != '"') { + char ch = s.charAt(i++); + if (ch == '\\') { + char e = s.charAt(i++); + switch (e) { + case 'n': b.append('\n'); break; + case 't': b.append('\t'); break; + case 'r': b.append('\r'); break; + case 'b': b.append('\b'); break; + case 'f': b.append('\f'); break; + case 'u': + b.append((char) Integer.parseInt(s.substring(i, i + 4), 16)); + i += 4; + break; + default: b.append(e); + } + } else { + b.append(ch); + } + } + i++; + return b.toString(); + } + int start = i; + while (i < s.length() && ",}] \n\r\t".indexOf(s.charAt(i)) < 0) { + i++; + } + String word = s.substring(start, i); + if ("true".equals(word)) { + return Boolean.TRUE; + } + if ("false".equals(word)) { + return Boolean.FALSE; + } + if ("null".equals(word)) { + return null; + } + return word.indexOf('.') >= 0 || word.indexOf('e') >= 0 ? (Object) Double.valueOf(word) + : (Object) Long.valueOf(word); + } + + private void ws() { + while (i < s.length() && Character.isWhitespace(s.charAt(i))) { + i++; + } + } + } +} diff --git a/scripts/bastion/pom.xml b/scripts/bastion/pom.xml new file mode 100644 index 00000000000..00bc9fad121 --- /dev/null +++ b/scripts/bastion/pom.xml @@ -0,0 +1,314 @@ + + 4.0.0 + com.codenameone.examples.bastion + bastion + 1.0-SNAPSHOT + pom + bastion + bastion + https://www.codenameone.com + + + MIT No Attribution + https://opensource.org/license/mit-0 + repo + The same terms as the LICENSE file beside this pom + + + + + shared + common + + + 8.0-SNAPSHOT + 8.0-SNAPSHOT + UTF-8 + 1.8 + 11 + 1.7.11 + 3.8.0 + 1.8 + 1.8 + 1.8 + bastion + + + + + com.codenameone + java-runtime + ${cn1.version} + + + com.codenameone + codenameone-core + ${cn1.version} + + + com.codenameone + codenameone-javase + ${cn1.version} + + + com.codenameone + codenameone-android-compat + ${cn1.version} + provided + + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + com.codenameone + codenameone-buildclient + ${cn1.version} + system + ${user.home}/.codenameone/CodeNameOneBuildClient.jar + + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.codehaus.mojo + exec-maven-plugin + 3.0.0 + + + maven-antrun-plugin + org.apache.maven.plugins + 3.1.0 + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + codenameone + https://repo.codenameone.com/maven2 + + true + + + false + + + + + + codenameone-plugins + https://repo.codenameone.com/maven2 + + true + + + false + + + + + + + + cn1-client-tests + + + codename1.platform + !backend + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + + javascript + + + codename1.platform + javascript + + + ${basedir}/javascript/pom.xml + + + + javascript + + + + ios + + + codename1.platform + ios + + + ${basedir}/ios/pom.xml + + + + ios + + + + win + + + codename1.platform + win + + + ${basedir}/win/pom.xml + + + + win + + + + linux + + + codename1.platform + linux + + + ${basedir}/linux/pom.xml + + + + linux + + + + + backend + + + codename1.platform + backend + + + ${basedir}/backend/pom.xml + + + + backend + + + + android + + + codename1.platform + android + + + ${basedir}/android/pom.xml + + + + android + + + + javase + + + codename1.platform + javase + + + ${basedir}/javase/pom.xml + + + + javase + + + + cn1libs + + + ${basedir}/cn1libs/pom.xml + + + + cn1libs + + + + diff --git a/scripts/bastion/run-chain-test.sh b/scripts/bastion/run-chain-test.sh new file mode 100755 index 00000000000..688d104f57c --- /dev/null +++ b/scripts/bastion/run-chain-test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# Real Ether through the bank: starts a development Ethereum node (geth +# --dev, in a container), points the server at it and runs +# backend/.../chain/ChainIntegrationTest -- a deposit credited from the chain, +# swept into the hot wallet, a withdrawal signed here, broadcast and +# confirmed, the books checked against the chain, and a withdrawal held when +# the ledger owes more than the chain holds. The offline known-answer tests +# (ChainVectorTest) run with it. +# +# run-chain-test.sh +# +# Needs docker or podman, JAVA_HOME on JDK 17 or newer and the Codename One +# artifacts in the local Maven repository (MAVEN_OPTS reaches the build, for +# -Dmaven.repo.local). The node is removed on exit. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PORT="${BASTION_CHAIN_PORT:-18545}" +IMAGE="${BASTION_GETH_IMAGE:-docker.io/ethereum/client-go:v1.14.12}" +NAME="bastion-chain-test-$$" +ENGINE="$(command -v docker || command -v podman || true)" +[ -n "$ENGINE" ] || { echo "run-chain-test: docker or podman is needed" >&2; exit 1; } + +cleanup() { "$ENGINE" rm -f "$NAME" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +"$ENGINE" run -d --name "$NAME" -p "127.0.0.1:$PORT:8545" "$IMAGE" \ + --dev --dev.period 1 --http --http.addr 0.0.0.0 --http.port 8545 \ + --http.api eth,net,web3 >/dev/null +for _ in $(seq 1 60); do + if curl -s -X POST -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' \ + "http://127.0.0.1:$PORT" | grep -q result; then + break + fi + sleep 1 +done + +# A throwaway seed, new every run: this chain and its coins vanish on exit. +export BASTION_CHAIN_RPC_URL="http://127.0.0.1:$PORT" +export BASTION_CHAIN_ID=1337 +export BASTION_CHAIN_CONFIRMATIONS=2 +export BASTION_CHAIN_SEED="$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')" +export BASTION_CHAIN_PERIOD_MS=3600000 + +cd "$HERE" +mvn -B -q -Dcodename1.platform=backend -pl backend -am test \ + -Dtest='ChainVectorTest,ChainIntegrationTest' -Dsurefire.failIfNoSpecifiedTests=false +echo "run-chain-test: passed" diff --git a/scripts/bastion/run-e2e.sh b/scripts/bastion/run-e2e.sh new file mode 100755 index 00000000000..0f6ce0888c8 --- /dev/null +++ b/scripts/bastion/run-e2e.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# The end-to-end run: starts the Bastion server on the `test` profile and +# drives it over HTTP the way the app does (e2e/LiveE2e.java). +# +# run-e2e.sh [--native] [--prebuilt] +# +# --native runs against the compiled server +# --prebuilt uses the server as it was last built, without building it again +# +# Two customers open accounts; one enrols an authenticator and signs in with +# password and code through the authorization-code flow with PKCE, registers +# a device key, and pays the other with a request that key signs, while the +# unsigned and the replayed copies are refused. The two trade until an order +# fills, and coins are refused on their way out until the address is on the +# allow-list and past its cooling-off. +# +# Needs JAVA_HOME on JDK 17 or newer and the Codename One artifacts this +# project builds against in the local Maven repository (MAVEN_OPTS reaches +# the build, for -Dmaven.repo.local). Nothing here opens a window. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PORT="${BASTION_PORT:-18090}" +LOG="${BASTION_SERVER_LOG:-$HERE/target/e2e-server.log}" +MODE="--jvm" +BUILD=1 +for arg in "$@"; do + case "$arg" in + --native) MODE="--native" ;; + --prebuilt) BUILD=0 ;; + *) echo "[bastion-e2e] unknown option: $arg" >&2; exit 2 ;; + esac +done + +mkdir -p "$HERE/target" +echo "[bastion-e2e] java: $(java -version 2>&1 | head -1)" +if [ "$BUILD" = 1 ]; then + "$HERE/backend/server.sh" build "$MODE" +fi + +echo "[bastion-e2e] starting the server on port $PORT" +CN1_PROFILE=test "$HERE/backend/server.sh" run "$MODE" --prebuilt --port "$PORT" >"$LOG" 2>&1 & +SERVER_PID=$! +cleanup() { + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +ready=0 +for _ in $(seq 1 90); do + if curl -fs -o /dev/null "http://localhost:$PORT/oauth2/jwks" 2>/dev/null; then + ready=1 + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 1 +done +if [ "$ready" != "1" ]; then + echo "[bastion-e2e] the server did not start; the end of its log:" >&2 + tail -n 80 "$LOG" >&2 || true + exit 1 +fi + +set +e +java "$HERE/e2e/LiveE2e.java" "http://localhost:$PORT" +STATUS=$? +set -e +if [ "$STATUS" -ne 0 ]; then + echo "[bastion-e2e] the run failed; the end of the server's log:" >&2 + tail -n 80 "$LOG" >&2 || true + exit "$STATUS" +fi diff --git a/scripts/bastion/run-web-e2e.sh b/scripts/bastion/run-web-e2e.sh new file mode 100755 index 00000000000..c26e364d3cb --- /dev/null +++ b/scripts/bastion/run-web-e2e.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# +# The web app, end to end: builds the Bastion server and the browser build of +# the app, starts the server with the app staged where it serves it from, and +# drives the hosted app in a headless browser (web-e2e.mjs). +# +# run-web-e2e.sh [--native] [--prebuilt] [--guide] +# +# --native runs the compiled server; --prebuilt skips both builds and uses what +# `backend/server.sh build --web` left. --guide runs the server on the +# development profile instead and, after the checks, signs in with its demo +# accounts and takes the screenshots in the user's and implementor's guides, +# into docs/developer-guide/img. +# +# What it proves: a person who opens the server's address gets the app, from +# this server, and it starts without an error at its sign-in form; then that +# a new customer can open an account, turn on the authenticator app, sign in +# again from a fresh browser with the password and a code (the app runs the +# authorization-code flow with PKCE from the page, the code handed back on +# /signin/code), see the dashboard and move money between two accounts; and +# that a passkey made in the browser signs in alone and is enough to register +# the browser as a signing device without the password. +# Screenshots of each step and the browser's log go to target/web-e2e. +# +# Needs JAVA_HOME on JDK 17 or newer, the Codename One artifacts this project builds +# against in the local Maven repository, and Node with Playwright and its +# Chromium: `npm ci && npx playwright install chromium` in scripts/, or a +# NODE_PATH that has it. Nothing here opens a window. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PORT="${BASTION_WEB_PORT:-18091}" +OUT="${BASTION_WEB_OUT:-$HERE/target/web-e2e}" +LOG="$OUT/server.log" +MODE="--jvm" +PREBUILT=0 +PROFILE=test +TOUR="" +for arg in "$@"; do + case "$arg" in + --native) MODE="--native" ;; + --prebuilt) PREBUILT=1 ;; + # A browser cannot attest itself, so on the default policy it may move money + # only between its own accounts; the guide shows a payment to a payee. + --guide) PROFILE=dev; export BASTION_DEVICE_POLICY_ENFORCE=false; TOUR="$(cd "$HERE/../../docs/developer-guide/img" && pwd)" ;; + *) echo "[bastion-web] unknown argument $arg" >&2; exit 2 ;; + esac +done + +if ! command -v node >/dev/null 2>&1; then + echo "[bastion-web] node is not on the PATH; the browser is driven with Playwright" >&2 + exit 2 +fi + +rm -rf "$OUT" +mkdir -p "$OUT" + +echo "[bastion-web] java: $(java -version 2>&1 | head -1)" +if [ "$PREBUILT" = 0 ]; then + "$HERE/backend/server.sh" build "$MODE" --web +fi +if [ ! -f "$HERE/backend/target/webapp/index.html" ]; then + echo "[bastion-web] no web app is staged in backend/target/webapp" >&2 + exit 1 +fi + +echo "[bastion-web] starting the server on port $PORT" +# Passkeys are accepted only from the listed origins: this one. +BASTION_WEBAUTHN_ORIGINS="http://localhost:$PORT" \ + CN1_PROFILE="$PROFILE" "$HERE/backend/server.sh" run "$MODE" --prebuilt --port "$PORT" >"$LOG" 2>&1 & +SERVER_PID=$! +cleanup() { + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +ready=0 +for _ in $(seq 1 90); do + if curl -fs -o /dev/null "http://localhost:$PORT/oauth2/jwks" 2>/dev/null; then + ready=1 + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 1 +done +if [ "$ready" != "1" ]; then + echo "[bastion-web] the server did not start; its log:" >&2 + cat "$LOG" >&2 || true + exit 1 +fi + +fail() { + echo "[bastion-web] FAILED: $*" >&2 + exit 1 +} + +# What no browser is needed to see: the app is at /, the API kept its paths, and +# the script that is most of the download goes out compressed. +headers="$(curl -fsS -D - -o /dev/null "http://localhost:$PORT/")" \ + || fail "the server did not answer at /" +echo "$headers" | grep -qi '^content-type: text/html' || fail "/ is not served as a page" +echo "$headers" | grep -qi '^cache-control: no-cache' || fail "/ is not revalidated" +headers="$(curl -fsS -H 'Accept-Encoding: gzip' -D - -o /dev/null \ + "http://localhost:$PORT/translated_app.js")" || fail "translated_app.js is not served" +echo "$headers" | grep -qi '^content-encoding: gzip' \ + || fail "translated_app.js was not served compressed" +status="$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/me")" +[ "$status" = "401" ] || fail "/api/me answered $status without a token, not 401" + +set +e +URL="http://localhost:$PORT/" OUT="$OUT" BASTION_WEB_TOUR="$TOUR" node "$HERE/web-e2e.mjs" +STATUS=$? +set -e + +if [ "$STATUS" -ne 0 ]; then + echo "[bastion-web] the end of the browser's log:" >&2 + tail -n 40 "$OUT/browser.log" >&2 || true + echo "[bastion-web] the end of the server's log:" >&2 + tail -n 40 "$LOG" >&2 || true + exit "$STATUS" +fi +echo "[bastion-web] passed; screenshots are in $OUT" diff --git a/scripts/bastion/shared/pom.xml b/scripts/bastion/shared/pom.xml new file mode 100644 index 00000000000..6b8bda19a42 --- /dev/null +++ b/scripts/bastion/shared/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + com.codenameone.examples.bastion + bastion + 1.0-SNAPSHOT + + com.codenameone.examples.bastion + bastion-shared + 1.0-SNAPSHOT + jar + bastion-shared + + + + 17 + 17 + + + + + com.codenameone + codenameone-core + provided + + + diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AccountApi.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AccountApi.java new file mode 100644 index 00000000000..10d543a5ea0 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AccountApi.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.Header; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// The account a customer signs in to: opening one, reading it, and the +/// second factor that protects it. +/// +/// Signing in itself is not here. It is the OAuth 2 authorization-code flow +/// with PKCE against `/login` and `/oauth2/**`, and passkeys against +/// `/webauthn/**`: none of it is a JSON call with a token, so none of it +/// belongs in a REST contract. +@RestClient +public interface AccountApi { + /// Opens an account, and a US dollar checking account with it. The one + /// call here that takes no token. + @POST("/api/account/register") + void register(@Body RegisterDto request, OnComplete> callback); + + @GET("/api/me") + void me(OnComplete> callback); + + @POST("/api/account/password") + void changePassword(@Body PasswordChangeDto change, OnComplete> callback); + + /// Starts adding an authenticator app. Nothing changes at sign-in until + /// [#confirmTotp] proves the app was set up. Both take the password after + /// a password-only sign-in ([TotpStartDto]). + @POST("/api/account/mfa/totp") + void startTotp(@Body TotpStartDto start, OnComplete> callback); + + /// Finishes adding the app with a code it shows, and answers the recovery + /// codes. + @POST("/api/account/mfa/totp/confirm") + void confirmTotp(@Body TotpCodeDto code, OnComplete> callback); + + /// Replaces the recovery codes. Takes a current code from the app, so a + /// stolen token alone cannot mint a way past the second factor. + @POST("/api/account/mfa/recovery-codes") + void newRecoveryCodes(@Body TotpCodeDto code, + OnComplete> callback); + + /// The account's passkeys. Adding one is a WebAuthn ceremony against the + /// signed-in session (`/webauthn/register`), not a call here. + @GET("/api/account/passkeys") + void passkeys(OnComplete>> callback); + + /// Removes a passkey and answers the ones left. Signed by a device of the + /// account or, with none, carrying the password ([PasskeyRemoveDto]). + @POST("/api/account/passkeys/{id}/remove") + void removePasskey(@Path("id") String id, @Header("Bastion-Signature") String signature, + @Body PasskeyRemoveDto request, OnComplete>> callback); +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AccountDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AccountDto.java new file mode 100644 index 00000000000..516cb496178 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AccountDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A balance the caller holds, in ledger units of its asset (see [AssetDto]). +@Mapped +public record AccountDto(String id, String asset, String kind, String name, long balance, boolean frozen, long createdAt) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AdjustmentDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AdjustmentDto.java new file mode 100644 index 00000000000..7c495641974 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AdjustmentDto.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A manual adjustment: `amount` ledger units credited to (positive) or +/// debited from (negative) the customer account, against operational +/// adjustments expense. `password` is the administrator's. +@Mapped +public record AdjustmentDto( + String accountId, + long amount, + String memo, + String password) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AdminApi.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AdminApi.java new file mode 100644 index 00000000000..b7d0486f0f1 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AdminApi.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.Header; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// What only an admin may ask or do: whether the records still hold, and +/// the operations on the books -- period close, adjustments and reversals. +/// +/// Every operation that changes the books takes the administrator's password +/// (step-up, held to the sign-in lockout) and is audited in its own +/// transaction. An adjustment or reversal worth more than +/// `bastion.approval.threshold-usd-cents` waits PENDING for a different +/// administrator (maker-checker); below it, it posts at once. +@RestClient +public interface AdminApi { + /// Walks the audit chain from its first link. + @GET("/api/admin/audit/verify") + void verifyAudit(OnComplete> callback); + + /// Checks every entry sums to zero in each asset and every balance is the + /// sum of its postings. + @GET("/api/admin/ledger/verify") + void verifyLedger(OnComplete> callback); + + /// Closes the books through a past time: revenue and expense move to + /// retained earnings and nothing may be posted at or before it again. + @POST("/api/admin/periods/close") + void closePeriod(@Body ClosePeriodDto request, OnComplete> callback); + + @POST("/api/admin/adjustments") + void adjust(@Header("Idempotency-Key") String idempotencyKey, @Body AdjustmentDto request, + OnComplete> callback); + + /// Corrects an entry the only way a ledger may: by posting its mirror + /// image. The entry itself is never changed. + @POST("/api/admin/journal/{id}/reverse") + void reverse(@Path("id") String id, @Header("Idempotency-Key") String idempotencyKey, + @Body ReversalRequestDto request, OnComplete> callback); + + /// Newest first: the pending ones, and the latest decided. + @GET("/api/admin/approvals") + void approvals(OnComplete>> callback); + + @POST("/api/admin/approvals/{id}/approve") + void approve(@Path("id") String id, @Body DecisionDto decision, + OnComplete> callback); + + @POST("/api/admin/approvals/{id}/reject") + void reject(@Path("id") String id, @Body DecisionDto decision, + OnComplete> callback); +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AllocationDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AllocationDto.java new file mode 100644 index 00000000000..7b5cd5dbc77 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AllocationDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// How much of a customer's holdings is in one asset, valued in US cents; +/// `basisPoints` of the total (10000 = all of it). +@Mapped +public record AllocationDto( + String asset, + long amount, + long usdCents, + int basisPoints) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ApprovalDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ApprovalDto.java new file mode 100644 index 00000000000..d569d553f52 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ApprovalDto.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// An administrator's adjustment or reversal. `status` is PENDING (waits for +/// a second administrator), EXECUTED or REJECTED; `kind` ADJUSTMENT or +/// REVERSAL. `resultEntryId` is the entry it posted. +@Mapped +public record ApprovalDto( + String id, + String kind, + String status, + String maker, + String checker, + String accountId, + String entryId, + String asset, + long amount, + long usdCents, + String memo, + String resultEntryId, + long createdAt, + long decidedAt) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AssetDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AssetDto.java new file mode 100644 index 00000000000..118f4c7f503 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AssetDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Something an account can hold. `decimals` is how many ledger units make +/// one whole unit: an amount of 12345 in an asset with 2 decimals is 123.45. +@Mapped +public record AssetDto(String code, String name, String kind, int decimals) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AssetTotalDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AssetTotalDto.java new file mode 100644 index 00000000000..e1e1018a5ce --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AssetTotalDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// The column totals of a trial balance in one asset; equal, or the books +/// are broken. +@Mapped +public record AssetTotalDto( + String asset, + long debits, + long credits, + boolean balanced) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AuditApi.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AuditApi.java new file mode 100644 index 00000000000..5f4c81a9ab5 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AuditApi.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// The audit trail, for staff only -- an ADMIN or an AUDITOR. Reading it is +/// recorded in it (`STAFF_READ`), so the auditors' own reads are on the +/// record the next auditor reads. +@RestClient +public interface AuditApi { + /// Walks the hash chain from its first link and names the first one that + /// does not hold. + @GET("/api/audit/chain") + void chain(OnComplete> callback); + + /// Links after sequence `after` (default 0), oldest first, at most + /// `limit` (default 100, at most 500). + @GET("/api/audit/events") + void events(@Query("after") String after, @Query("limit") String limit, + OnComplete>> callback); + + /// Everything recorded about one entity -- an account, an entry, a + /// username, an approval -- as subject or as actor, newest first. + @GET("/api/audit/trail") + void trail(@Query("subject") String subject, OnComplete>> callback); + + /// Staff reads of the books and the trail, newest first. + @GET("/api/audit/access-log") + void accessLog(@Query("limit") String limit, OnComplete>> callback); + + /// Exports `name` -- `journal`, `trial-balance` or `audit-chain` -- as + /// `format` csv (default) or json. The digest of what was handed out is + /// recorded in the chain. + @GET("/api/audit/evidence/{name}") + void evidence(@Path("name") String name, @Query("format") String format, + @Query("asOf") String asOf, OnComplete> callback); +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AuditEventDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AuditEventDto.java new file mode 100644 index 00000000000..b7dc30dbc44 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AuditEventDto.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// One link of the audit chain, with both hashes so it can be checked +/// independently. +@Mapped +public record AuditEventDto( + long seq, + long at, + String actor, + String action, + String subject, + String detail, + String address, + String prevHash, + String hash) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BalancePointDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BalancePointDto.java new file mode 100644 index 00000000000..e1a5112e863 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BalancePointDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// An account's closing balance on the UTC day starting at `day`. +@Mapped +public record BalancePointDto( + long day, + long balance) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BalanceSheetDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BalanceSheetDto.java new file mode 100644 index 00000000000..f675eb9b4bd --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BalanceSheetDto.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// Assets = liabilities + equity, per asset, as of `asOf`. Equity includes +/// the current, unclosed period's earnings. `balanced` is that identity +/// holding in every asset. +@Mapped +public record BalanceSheetDto( + long asOf, + List lines, + List totals, + long totalAssetsUsdCents, + boolean balanced) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BankingApi.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BankingApi.java new file mode 100644 index 00000000000..8168b08fb28 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BankingApi.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.DELETE; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.Header; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.PUT; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// Money: the caller's accounts, moving money between them and to other +/// customers, payees, cards and the devices allowed to sign for the caller. +/// +/// Two headers recur: +/// +/// - **`Idempotency-Key`** on every call that moves money: 16 to 80 random +/// characters, made once per payment and sent again unchanged on every retry +/// of it. However many times the request arrives, the money moves once and +/// every answer is the same receipt. Reusing a key for a different payment +/// is refused (422). +/// - **`Bastion-Signature`** where a request must come from a registered +/// device: transfers above the step-up threshold, anything paying someone +/// else, withdrawals, adding, renaming and removing a payee, unfreezing a +/// card, raising a card's limit and revoking a device (which takes the +/// account password in its body instead when no other device signs). It is +/// `v1;device=;ts=;nonce=;sig=`, where `sig` +/// is the device key's ES256 signature (r and s, 32 bytes each, as JOSE +/// writes them) of +/// `METHOD|path|hex(sha256(body))|idempotency-key|nonce|ts` (the key empty +/// for a call without one, so a signed request cannot be replayed under a +/// fresh key). The body is the exact bytes sent, so the client serializes once and signs what it sends. A stolen +/// access token alone therefore cannot move money out: it needs the private +/// key, which never leaves the device's key store. +/// +/// A null header argument is left out of the request. +@RestClient +public interface BankingApi { + /// The assets the bank holds, from the ledger's own table: the app counts + /// every amount in the decimals named here rather than in a copy of them. + @GET("/api/assets") + void assets(OnComplete>> callback); + + @GET("/api/accounts") + void accounts(OnComplete>> callback); + + @POST("/api/accounts") + void open(@Body OpenAccountDto request, OnComplete> callback); + + /// The newest 100 postings. Another customer's account answers 404, as + /// one that does not exist does. + @GET("/api/accounts/{id}/postings") + void history(@Path("id") String id, OnComplete>> callback); + + @POST("/api/transfers") + void transfer(@Header("Idempotency-Key") String idempotencyKey, + @Header("Bastion-Signature") String signature, @Body TransferDto transfer, + OnComplete> callback); + + /// Simulated cash arriving. Development and test servers only; elsewhere + /// the route answers 404. + @POST("/api/accounts/{id}/deposit") + void deposit(@Path("id") String id, @Header("Idempotency-Key") String idempotencyKey, + @Body CashDto cash, OnComplete> callback); + + /// Money, or coins, leaving the bank. Always signed. + @POST("/api/accounts/{id}/withdraw") + void withdraw(@Path("id") String id, @Header("Idempotency-Key") String idempotencyKey, + @Header("Bastion-Signature") String signature, @Body CashDto cash, + OnComplete> callback); + + @GET("/api/payees") + void payees(OnComplete>> callback); + + /// Always signed: a new payee is where stolen money would go. + @POST("/api/payees") + void addPayee(@Header("Bastion-Signature") String signature, @Body NewPayeeDto payee, + OnComplete> callback); + + @PUT("/api/payees/{id}") + void renamePayee(@Path("id") String id, @Header("Bastion-Signature") String signature, + @Body NewPayeeDto payee, + OnComplete> callback); + + @DELETE("/api/payees/{id}") + void removePayee(@Path("id") String id, @Header("Bastion-Signature") String signature, + OnComplete> callback); + + @GET("/api/cards") + void cards(OnComplete>> callback); + + /// Issues the virtual card of a checking account; one per account. Signed: + /// a new card is a new way for money to leave the account. + @POST("/api/accounts/{id}/card") + void issueCard(@Path("id") String accountId, @Header("Bastion-Signature") String signature, + OnComplete> callback); + + @POST("/api/cards/{id}/freeze") + void freezeCard(@Path("id") String id, OnComplete> callback); + + @POST("/api/cards/{id}/unfreeze") + void unfreezeCard(@Path("id") String id, @Header("Bastion-Signature") String signature, + OnComplete> callback); + + @PUT("/api/cards/{id}/limit") + void cardLimit(@Path("id") String id, @Header("Bastion-Signature") String signature, + @Body CardLimitDto limit, + OnComplete> callback); + + /// The card network asking to charge the card. Simulated, so development + /// and test servers only. + @POST("/api/cards/{id}/spend") + void spend(@Path("id") String id, @Header("Idempotency-Key") String idempotencyKey, + @Body CardSpendDto spend, OnComplete> callback); + + @GET("/api/devices") + void devices(OnComplete>> callback); + + @POST("/api/devices") + void registerDevice(@Body DeviceRegistrationDto device, + OnComplete> callback); + + /// Withdraws a device's key, for a phone that was lost. + @POST("/api/devices/{id}/revoke") + void revokeDevice(@Path("id") String id, @Header("Bastion-Signature") String signature, + @Body DeviceRevokeDto request, OnComplete> callback); +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BookDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BookDto.java new file mode 100644 index 00000000000..424d57ff668 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BookDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// A market's order book, aggregated by price: best first on both sides. It +/// names no order and no owner. +@Mapped +public record BookDto(String market, List bids, List asks) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BooksApi.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BooksApi.java new file mode 100644 index 00000000000..80084b06f0d --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BooksApi.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.Header; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// The bank's books, read: for staff only -- an ADMIN or an AUDITOR -- and +/// every call is itself recorded in the audit chain (`STAFF_READ`). +/// +/// Amounts are ledger units of the line's asset (cents, satoshi, gwei), as +/// debits and credits. Times are epoch milliseconds; an absent or 0 `asOf` or +/// `to` means now, an absent `from` the beginning. +@RestClient +public interface BooksApi { + /// Proof of reserves: what the chain holds against what the ledger owes, + /// for the coin on a real chain. + @GET("/api/books/custody") + void custody(OnComplete> callback); + + /// The chart of accounts. + @GET("/api/books/chart") + void chart(OnComplete>> callback); + + /// Journal entries with all their legs, newest first, at most `limit` + /// (default 50, at most 200). + @GET("/api/books/journal") + void journal(@Query("from") String from, @Query("to") String to, @Query("limit") String limit, + OnComplete>> callback); + + @GET("/api/books/journal/{id}") + void entry(@Path("id") String id, OnComplete> callback); + + /// The general ledger of one chart code in one asset: every posting to + /// every account under the code, oldest first, with the running balance + /// (the latest 2000; see [GeneralLedgerDto#truncated]). + @GET("/api/books/ledger") + void generalLedger(@Query("code") String code, @Query("asset") String asset, + @Query("from") String from, @Query("to") String to, + OnComplete> callback); + + /// One ledger account as a T-account. + @GET("/api/books/accounts/{id}/t-account") + void tAccount(@Path("id") String id, @Query("from") String from, @Query("to") String to, + OnComplete> callback); + + @GET("/api/books/trial-balance") + void trialBalance(@Query("asOf") String asOf, OnComplete> callback); + + @GET("/api/books/balance-sheet") + void balanceSheet(@Query("asOf") String asOf, OnComplete> callback); + + /// The profit and loss between `from` and `to`. + @GET("/api/books/income-statement") + void incomeStatement(@Query("from") String from, @Query("to") String to, + OnComplete> callback); + + /// Sub-ledgers against their control accounts, kept balances against + /// postings, and every entry's debits against its credits. + @GET("/api/books/reconciliation") + void reconciliation(OnComplete> callback); + + @GET("/api/books/periods") + void periods(OnComplete>> callback); + + /// Entries and debits per UTC day over the last `days` (default 30). + @GET("/api/books/volume") + void volume(@Query("days") String days, OnComplete>> callback); +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BreakDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BreakDto.java new file mode 100644 index 00000000000..850573664e4 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BreakDto.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// One reconciliation break. `type` is CACHED_BALANCE (an account's kept +/// balance is not its postings), CONTROL (a control account and its +/// sub-ledger disagree) or UNBALANCED_ENTRY (an entry's debits are not its +/// credits). +@Mapped +public record BreakDto( + String type, + String asset, + String reference, + long expected, + long actual, + long difference) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CandleDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CandleDto.java new file mode 100644 index 00000000000..bc1b491722a --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CandleDto.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// An OHLC candle starting at `start`; `volume` is base ledger units traded, +/// `quoteVolume` quote units paid. A candle with no trades carries the +/// previous close. +@Mapped +public record CandleDto( + long start, + long open, + long high, + long low, + long close, + long volume, + long quoteVolume, + int trades) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CardDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CardDto.java new file mode 100644 index 00000000000..b7f74860d94 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CardDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A virtual card on a checking account. Only the last four digits exist +/// here: the full number is the card processor's, and never stored. +@Mapped +public record CardDto(String id, String accountId, String last4, boolean frozen, long dailyLimit, long createdAt) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CardLimitDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CardLimitDto.java new file mode 100644 index 00000000000..fc69a159561 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CardLimitDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// The most a card may spend in 24 hours, in ledger units of its account. +@Mapped +public record CardLimitDto(long dailyLimit) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CardSpendDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CardSpendDto.java new file mode 100644 index 00000000000..64e5aba63c9 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CardSpendDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A simulated card payment, standing in for the card network's +/// authorization request. +@Mapped +public record CardSpendDto(long amount, String merchant) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CashDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CashDto.java new file mode 100644 index 00000000000..47018f6766b --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CashDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Money arriving from, or leaving to, the world outside the bank. +@Mapped +public record CashDto(long amount, String memo) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CashflowDayDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CashflowDayDto.java new file mode 100644 index 00000000000..cad91a68034 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CashflowDayDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Money in and out on the UTC day starting at `day`; moves between the +/// customer's own accounts are left out. +@Mapped +public record CashflowDayDto( + long day, + long inflow, + long outflow, + long net) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ChainReportDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ChainReportDto.java new file mode 100644 index 00000000000..3854c729265 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ChainReportDto.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// The audit hash chain, walked: `firstBrokenSeq` is the first link that +/// does not hold, or 0 when the chain is intact. +@Mapped +public record ChainReportDto( + boolean intact, + long checked, + long headSeq, + String headHash, + long firstBrokenSeq, + List problems) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ChainTransferDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ChainTransferDto.java new file mode 100644 index 00000000000..ef12519c16a --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ChainTransferDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Coins that crossed to or from the chain: `direction` IN or OUT, `status` +/// QUEUED, SIGNED, BROADCAST, CONFIRMED or FAILED, `txHash` the transaction a +/// block explorer shows (empty until signed). +@Mapped +public record ChainTransferDto(String id, String direction, String asset, String address, long amount, String status, String txHash, long blockNumber, long fee, String detail, long createdAt) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ChartAccountDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ChartAccountDto.java new file mode 100644 index 00000000000..4a580aeb0a5 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ChartAccountDto.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// One line of the chart of accounts: a code, its class (ASSET, LIABILITY, +/// EQUITY, REVENUE, EXPENSE), its normal balance (DEBIT or CREDIT) and the +/// ledger account kind it is made of. +@Mapped +public record ChartAccountDto( + String code, + String name, + String accountClass, + String normalBalance, + String kind) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ClosePeriodDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ClosePeriodDto.java new file mode 100644 index 00000000000..ff0ae483335 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ClosePeriodDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Closes the books through `through` (epoch millis, in the past and after +/// the last close). `password` is the administrator's, as step-up. +@Mapped +public record ClosePeriodDto( + long through, + String password) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ControlDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ControlDto.java new file mode 100644 index 00000000000..d8675f78f2f --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ControlDto.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A control account against its sub-ledger in one asset: `control` is what +/// the general ledger says (postings), `subledger` the sum of the individual +/// accounts' kept balances. +@Mapped +public record ControlDto( + String asset, + String code, + String name, + long control, + long subledger, + long difference) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CryptoAddressDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CryptoAddressDto.java new file mode 100644 index 00000000000..6ac2278689b --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CryptoAddressDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// An allow-listed withdrawal address, usable from `usableAt`. +@Mapped +public record CryptoAddressDto(String id, String asset, String address, String label, long usableAt, long createdAt) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CryptoWithdrawalDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CryptoWithdrawalDto.java new file mode 100644 index 00000000000..101695b557f --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CryptoWithdrawalDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Coins out of a wallet to an allow-listed address. +@Mapped +public record CryptoWithdrawalDto(String addressId, long amount) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CustodyDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CustodyDto.java new file mode 100644 index 00000000000..85f36d62c6f --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CustodyDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Proof of reserves for one coin: what the chain holds (`onChain`, the hot +/// wallet plus every deposit address, readable on any explorer), what the +/// ledger owes customers (`owed`, in transit included), and the hot wallet's +/// own address and balance. +@Mapped +public record CustodyDto(boolean live, String asset, long chainId, String hotWallet, long hotBalance, long onChain, long owed, long inTransit) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DecisionDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DecisionDto.java new file mode 100644 index 00000000000..49f5b8c020d --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DecisionDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A second administrator's approval or rejection; `password` is theirs. +@Mapped +public record DecisionDto( + String password, + String reason) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DepositAddressDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DepositAddressDto.java new file mode 100644 index 00000000000..ed419b42752 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DepositAddressDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Where a customer sends coins to deposit them: their own address on +/// `network` (chain id `chainId`), credited after `confirmations` blocks. +/// `live` is false while the sample's chain is simulated, and the address is +/// then empty. +@Mapped +public record DepositAddressDto(boolean live, String asset, String address, String network, long chainId, int confirmations) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceDto.java new file mode 100644 index 00000000000..4ea4df62d6f --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A device that may sign requests for the caller. `trusted` is attested and +/// not rooted: only a trusted device may send money to someone else. +@Mapped +public record DeviceDto(String id, String platform, boolean rooted, boolean attested, boolean trusted, long createdAt) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceRegistrationDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceRegistrationDto.java new file mode 100644 index 00000000000..9d5b23358e2 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceRegistrationDto.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Registers a device's signing key, an EC P-256 public key as base64url +/// SubjectPublicKeyInfo DER, with the integrity signals the device reported +/// and the platform integrity token (Play Integrity, App Attest) if it has one. +/// +/// `password` is the account password, asked again when the account has no +/// authenticator app: registering a signing key needs a sign-in no more than +/// a few minutes old, and proof beyond the access token that it is the owner. +@Mapped +public record DeviceRegistrationDto(String publicKey, String platform, boolean rooted, boolean attested, String integrityToken, + String password) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceRevokeDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceRevokeDto.java new file mode 100644 index 00000000000..376fea46ed9 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceRevokeDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Revokes a device. Taking a device off an account is a step-up: the request +/// is signed by one of the account's devices -- another one, or the one being +/// revoked -- or, with no device to sign it, carries the account password. A +/// stolen token alone must not lock the owner's own phone out. +@Mapped +public record DeviceRevokeDto(String password) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/EvidenceDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/EvidenceDto.java new file mode 100644 index 00000000000..60adf51ab51 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/EvidenceDto.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// An exported piece of evidence: `content` in `format` (csv or json), and +/// the SHA-256 of its UTF-8 bytes, which is also recorded in the audit chain +/// when it is exported. +@Mapped +public record EvidenceDto( + String name, + String format, + String contentType, + long generatedAt, + String generatedBy, + String sha256, + String content) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ExchangeApi.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ExchangeApi.java new file mode 100644 index 00000000000..84205fec1fa --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ExchangeApi.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.DELETE; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.Header; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// The crypto exchange, and the wallets' way out of the bank. +/// +/// Trading needs no signature: an order moves value only between the +/// customer's own accounts and the counterparty's, inside the bank, and it is +/// as reversible as the customer's next order. Coins *leaving* the bank are +/// another matter -- a blockchain transaction is never reversed -- so a +/// withdrawal is signed by a trusted device, goes only to an address the +/// customer allow-listed a cooling-off period earlier, and is held to limits +/// valued at the index price. +@RestClient +public interface ExchangeApi { + @GET("/api/exchange/markets") + void markets(OnComplete>> callback); + + @GET("/api/exchange/markets/{id}/book") + void book(@Path("id") String market, OnComplete> callback); + + @GET("/api/exchange/markets/{id}/ticker") + void ticker(@Path("id") String market, OnComplete> callback); + + /// Places an order. The `Idempotency-Key` makes a retry answer the order + /// the first attempt placed instead of placing a second. Always signed by + /// a trusted device, as a payment is: selling into another customer's + /// resting order moves value to them. + @POST("/api/exchange/orders") + void place(@Header("Idempotency-Key") String idempotencyKey, + @Header("Bastion-Signature") String signature, @Body NewOrderDto order, + OnComplete> callback); + + @DELETE("/api/exchange/orders/{id}") + void cancel(@Path("id") String id, OnComplete> callback); + + /// The caller's open orders, newest first. + @GET("/api/exchange/orders") + void openOrders(OnComplete>> callback); + + /// The caller's fills, newest first. + @GET("/api/exchange/trades") + void trades(OnComplete>> callback); + + @GET("/api/crypto/addresses") + void addresses(OnComplete>> callback); + + /// Allow-lists an address. Signed, like adding a payee. + @POST("/api/crypto/addresses") + void addAddress(@Header("Bastion-Signature") String signature, + @Body NewCryptoAddressDto address, OnComplete> callback); + + /// Takes an address off the list. Signed, like removing a payee: the list + /// is what the customer reads before a withdrawal. + @DELETE("/api/crypto/addresses/{id}") + void removeAddress(@Path("id") String id, @Header("Bastion-Signature") String signature, + OnComplete> callback); + + /// Coins out of wallet `id` to an allow-listed address. Always signed. + /// The caller's deposit address for `asset`, made the first time. + @GET("/api/crypto/deposit-address/{asset}") + void depositAddress(@Path("asset") String asset, OnComplete> callback); + + /// The caller's coins that crossed to or from the chain, newest first. + @GET("/api/crypto/chain-transfers") + void chainTransfers(OnComplete>> callback); + + @POST("/api/accounts/{id}/crypto-withdraw") + void withdrawCrypto(@Path("id") String id, @Header("Idempotency-Key") String idempotencyKey, + @Header("Bastion-Signature") String signature, @Body CryptoWithdrawalDto withdrawal, + OnComplete> callback); +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/GeneralLedgerDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/GeneralLedgerDto.java new file mode 100644 index 00000000000..18b2653fd45 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/GeneralLedgerDto.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// The general ledger of one chart code in one asset between `from` and +/// `to`. `lines` holds at most the latest 2000 postings, oldest first, and +/// `truncated` says when there were more; the totals and both balances are +/// summed over every posting in the window, never just the listed ones. +@Mapped +public record GeneralLedgerDto( + String code, + String asset, + long from, + long to, + long openingBalance, + List lines, + long totalDebits, + long totalCredits, + long closingBalance, + boolean truncated) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/IncomeStatementDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/IncomeStatementDto.java new file mode 100644 index 00000000000..8c8b58a2d93 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/IncomeStatementDto.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// Revenue less expenses between `from` and `to`, per asset, closing entries +/// excluded so a closed period still shows what it earned. +@Mapped +public record IncomeStatementDto( + long from, + long to, + List lines, + List totals, + long netIncomeUsdCents) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/InsightsApi.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/InsightsApi.java new file mode 100644 index 00000000000..da9a747fed9 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/InsightsApi.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// The numbers behind a customer's charts, all of their own accounts, and the +/// market candles anyone signed in may see. +@RestClient +public interface InsightsApi { + /// The account's closing balance for each of the last `days` UTC days + /// (default 30, at most 366), today last. + @GET("/api/accounts/{id}/balance-history") + void balanceHistory(@Path("id") String id, @Query("days") String days, + OnComplete>> callback); + + /// Money in and out of the customer's accounts in `asset` (default USD) + /// per UTC day over the last `days` (default 30, at most 366). + @GET("/api/insights/cashflow") + void cashflow(@Query("asset") String asset, @Query("days") String days, + OnComplete>> callback); + + /// What the customer holds, per asset, valued in US cents. + @GET("/api/insights/allocation") + void allocation(OnComplete>> callback); + + /// OHLC candles of `interval` milliseconds (default an hour; one minute + /// to one day), the newest `limit` (default 168, at most 500). + @GET("/api/exchange/markets/{id}/candles") + void candles(@Path("id") String id, @Query("interval") String interval, + @Query("limit") String limit, OnComplete>> callback); +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/JournalEntryDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/JournalEntryDto.java new file mode 100644 index 00000000000..70c16b5887d --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/JournalEntryDto.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// A journal entry with all its legs. `reverses` names the entry this one +/// reverses and `reversedBy` the entry that reversed this one; either is +/// empty when there is none. +@Mapped +public record JournalEntryDto( + String id, + long at, + String kind, + String initiator, + String memo, + String reverses, + String reversedBy, + List legs) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/JournalLegDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/JournalLegDto.java new file mode 100644 index 00000000000..5a83d453449 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/JournalLegDto.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// One leg of a journal entry, as Dr/Cr: exactly one of `debit` and `credit` +/// is non-zero, in the asset's ledger units. +@Mapped +public record JournalLegDto( + String accountId, + String code, + String accountName, + String owner, + String asset, + long debit, + long credit) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/LedgerLineDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/LedgerLineDto.java new file mode 100644 index 00000000000..39d4d3283ad --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/LedgerLineDto.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// One posting in a general ledger or T-account, with the running `balance` +/// after it, on the account class's normal side. +@Mapped +public record LedgerLineDto( + String entryId, + long at, + String kind, + String memo, + String accountId, + String owner, + String code, + String asset, + long debit, + long credit, + long balance) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/LevelDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/LevelDto.java new file mode 100644 index 00000000000..68745459f19 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/LevelDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// One price of an order book: the open quantity at it, and how many orders. +@Mapped +public record LevelDto(long price, long quantity, int orders) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/MarketDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/MarketDto.java new file mode 100644 index 00000000000..405fb91f7ca --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/MarketDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A market: `base` traded for `quote`. Prices are quote ledger units per +/// one whole base unit; `lot` is the smallest quantity, in base ledger units. +@Mapped +public record MarketDto(String id, String base, String quote, long lot, long indexPrice, long lastPrice) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/NewCryptoAddressDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/NewCryptoAddressDto.java new file mode 100644 index 00000000000..cf2b1334861 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/NewCryptoAddressDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// An address to allow-list for withdrawals of `asset`. +@Mapped +public record NewCryptoAddressDto(String asset, String address, String label) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/NewOrderDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/NewOrderDto.java new file mode 100644 index 00000000000..1c7312461bc --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/NewOrderDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// An order to place. `side` is BUY or SELL, `type` LIMIT or MARKET. A limit +/// order names `price` and `quantity`; a market sell names `quantity`, and a +/// market buy `budget`: the quote amount to spend, fee included. +@Mapped +public record NewOrderDto(String market, String side, String type, long price, long quantity, long budget) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/NewPayeeDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/NewPayeeDto.java new file mode 100644 index 00000000000..4323644b2da --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/NewPayeeDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A payee by the e-mail address their customer account is under. +@Mapped +public record NewPayeeDto(String email, String nickname) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/OpenAccountDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/OpenAccountDto.java new file mode 100644 index 00000000000..cd036818617 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/OpenAccountDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Opens a SAVINGS or CHECKING account in a fiat asset, or a WALLET in a crypto one. +@Mapped +public record OpenAccountDto(String asset, String kind, String name) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/OrderDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/OrderDto.java new file mode 100644 index 00000000000..18d11d1b761 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/OrderDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// One of the caller's orders. +@Mapped +public record OrderDto(String id, String market, String side, String type, long price, long quantity, long budget, long filled, long settled, long fee, String status, long createdAt) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PasskeyDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PasskeyDto.java new file mode 100644 index 00000000000..3f625b23862 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PasskeyDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A passkey on the account. `id` is the credential id, base64url; `created` +/// and `lastUsed` are epoch milliseconds. +@Mapped +public record PasskeyDto(String id, String label, long created, long lastUsed) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PasskeyRemoveDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PasskeyRemoveDto.java new file mode 100644 index 00000000000..bc505a885c9 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PasskeyRemoveDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Removes a passkey. A step-up like revoking a device: the request is +/// signed by one of the account's devices or, with none to sign it, carries +/// the account password. A stolen token alone must not strip the owner's +/// passkeys, which are how they sign in again. +@Mapped +public record PasskeyRemoveDto(String password) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PasswordChangeDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PasswordChangeDto.java new file mode 100644 index 00000000000..21935956a23 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PasswordChangeDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A new password, and the current one to prove the caller is the owner and +/// not somebody holding their unlocked phone. +@Mapped +public record PasswordChangeDto(String current, String replacement) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PayeeDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PayeeDto.java new file mode 100644 index 00000000000..5bf6f17ef2d --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PayeeDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Someone the caller may pay. Until `coolingOffUntil` only a small amount may +/// reach them, so a payee slipped in by whoever stole a session cannot be +/// emptied into at once. +@Mapped +public record PayeeDto(String id, String email, String nickname, long createdAt, long coolingOffUntil) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PeriodDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PeriodDto.java new file mode 100644 index 00000000000..7b5bcf23c02 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PeriodDto.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// A closed accounting period: everything at or before `closedThrough` is +/// final; `entries` are the closing entries it posted. +@Mapped +public record PeriodDto( + long seq, + long closedThrough, + String closedBy, + long closedAt, + List entries) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PostingDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PostingDto.java new file mode 100644 index 00000000000..249081fe0a7 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PostingDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// One line of an account's history: `amount` in (positive) or out (negative). +/// `kind` and `memo` are the ledger entry's -- what moved the money (CARD, +/// TRANSFER, DEPOSIT...) and its description, a merchant or the note the +/// sender typed -- so a statement can say what each line was. +@Mapped +public record PostingDto(String id, String entryId, String asset, long amount, long createdAt, + String kind, String memo) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ReceiptDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ReceiptDto.java new file mode 100644 index 00000000000..1b7667c71c3 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ReceiptDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// What a movement of money did. A retry with the same Idempotency-Key answers +/// the same receipt, never a second movement. +@Mapped +public record ReceiptDto(String entryId, String kind, String fromAccountId, String toAccountId, String asset, long amount, String memo, long createdAt) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ReconciliationDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ReconciliationDto.java new file mode 100644 index 00000000000..97355886b71 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ReconciliationDto.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// The result of reconciling the books at `at`: `clean` when there are no +/// breaks. +@Mapped +public record ReconciliationDto( + long at, + boolean clean, + List controls, + List breaks) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/RecoveryCodesDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/RecoveryCodesDto.java new file mode 100644 index 00000000000..ae58f6b4406 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/RecoveryCodesDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// Ten one-use codes that stand in for the authenticator app when it is lost. +/// The server keeps only their hashes: this is the one time they are shown. +@Mapped +public record RecoveryCodesDto(List codes) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/Refusal.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/Refusal.java new file mode 100644 index 00000000000..379a293b28a --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/Refusal.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +/// How the server words a refusal so the app can say it in the user's +/// language: a stable code, the values the sentence names, and the English +/// sentence for any client that does not know the code. +/// +/// ``` +/// (payee-already-list) That payee is already on your list +/// (address-wrong-asset|BTC) That address is for BTC +/// ``` +/// +/// The code is what the app looks up (`error.` in its bundle, whose +/// English entry is the sentence with `{0}`, `{1}`... where the values go); +/// it never changes once shipped, while the English may be reworded freely. A +/// value never contains `|` or `)`: they are asset ids, statuses and numbers. +/// Parentheses, not brackets: a body that starts with `[` reads as JSON to +/// a client that sniffs it. +/// +/// Read with `indexOf` and `substring` only, since the app's class library +/// has no `String.split`. +public final class Refusal { + private Refusal() { + } + + /// The wire form of a refusal. `english` is the sentence with `{0}`... + /// for `values`, filled in here. + public static String format(String code, String english, Object... values) { + StringBuilder out = new StringBuilder("(").append(code); + String[] texts = new String[values.length]; + for (int iter = 0; iter < values.length; iter++) { + texts[iter] = String.valueOf(values[iter]); + out.append('|').append(texts[iter]); + } + return out.append(") ").append(fill(english, texts)).toString(); + } + + /// The code of a refusal body, or null when the body is not one. + public static String code(String body) { + int end = end(body); + if (end < 0) { + return null; + } + int bar = body.indexOf('|'); + return body.substring(1, bar > 0 && bar < end ? bar : end); + } + + /// The values a refusal names, in order. + public static String[] values(String body) { + int end = end(body); + if (end < 0) { + return new String[0]; + } + int count = 0; + for (int iter = 1; iter < end; iter++) { + if (body.charAt(iter) == '|') { + count++; + } + } + String[] out = new String[count]; + int at = body.indexOf('|'); + for (int iter = 0; iter < count; iter++) { + int next = body.indexOf('|', at + 1); + if (next < 0 || next > end) { + next = end; + } + out[iter] = body.substring(at + 1, next); + at = next; + } + return out; + } + + /// The English sentence of a refusal, or the whole body when it is not one. + public static String english(String body) { + int end = end(body); + if (end < 0) { + return body; + } + return body.substring(Math.min(body.length(), end + 2)); + } + + /// `pattern` with each `{n}` replaced by `values[n]`. + public static String fill(String pattern, String[] values) { + String text = pattern; + for (int iter = 0; iter < values.length; iter++) { + String mark = "{" + iter + "}"; + int at = text.indexOf(mark); + while (at >= 0) { + text = text.substring(0, at) + values[iter] + text.substring(at + mark.length()); + at = text.indexOf(mark, at + values[iter].length()); + } + } + return text; + } + + private static int end(String body) { + if (body == null || body.length() < 3 || body.charAt(0) != '(') { + return -1; + } + int end = body.indexOf(')'); + // A code has no spaces, so a bracket after the first space is part of + // a sentence, not the end of a code. + int space = body.indexOf(' '); + return end > 1 && (space < 0 || space > end) ? end : -1; + } +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/RegisterDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/RegisterDto.java new file mode 100644 index 00000000000..d21974a8c28 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/RegisterDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// What opening an account takes. The password travels once, in the body of a +/// request over TLS, and the server keeps only its hash. +@Mapped +public record RegisterDto(String email, String password, String displayName) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ReversalRequestDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ReversalRequestDto.java new file mode 100644 index 00000000000..a31f09f4811 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ReversalRequestDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Asks to reverse an entry; `password` is the administrator's. +@Mapped +public record ReversalRequestDto( + String memo, + String password) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/SignedRequest.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/SignedRequest.java new file mode 100644 index 00000000000..70b6477f160 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/SignedRequest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +/// The one definition of what a device signs, read by both the app (which +/// signs) and the server (which checks): a request is signed over +/// +/// ``` +/// METHOD|target|hex(sha256(body bytes))|idempotency-key|nonce|ts +/// ``` +/// +/// where `target` is the path with its query string exactly as the request +/// line carries it, the body is the exact bytes on the wire (an empty array +/// when there are none), the hex is lower case, `idempotency-key` is the +/// request's `Idempotency-Key` header (empty when it has none) -- so a captured +/// signature cannot be replayed under a fresh key to move the money again -- +/// `nonce` is 16 to 64 URL-safe +/// characters used once, and `ts` is the device's clock in milliseconds. The +/// text is UTF-8, signed with ECDSA P-256 and SHA-256, and the signature sent +/// as base64url of the 64-byte `r || s` (the JOSE form, as in a JWT) in +/// +/// ``` +/// Bastion-Signature: v1;device=;ts=;nonce=;sig= +/// ``` +/// +/// Kept free of every library so that it compiles, unchanged, into both. +public final class SignedRequest { + /// The header the signature travels in. + public static final String HEADER = "Bastion-Signature"; + /// The scheme every header starts with; a later one would be `v2;`. + public static final String SCHEME = "v1"; + /// How far the device clock may be from the server's, either way. + public static final long SKEW_MILLIS = 120000L; + + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private SignedRequest() { + } + + /// The text that is signed. `bodySha256` is the SHA-256 of the body bytes; + /// `idempotencyKey` is null or empty when the request carries none. + public static String canonical(String method, String target, byte[] bodySha256, + String idempotencyKey, String nonce, long ts) { + return method + "|" + target + "|" + hex(bodySha256) + "|" + + (idempotencyKey == null ? "" : idempotencyKey) + "|" + nonce + "|" + ts; + } + + /// The header's value. + public static String header(String deviceId, long ts, String nonce, String signature) { + return SCHEME + ";device=" + deviceId + ";ts=" + ts + ";nonce=" + nonce + ";sig=" + + signature; + } + + /// One `name=value` field of a header, or null. + public static String field(String header, String name) { + if (header == null) { + return null; + } + String wanted = name + "="; + int start = 0; + while (start <= header.length()) { + int end = header.indexOf(';', start); + if (end < 0) { + end = header.length(); + } + if (header.startsWith(wanted, start)) { + return header.substring(start + wanted.length(), end); + } + start = end + 1; + } + return null; + } + + /// Whether `nonce` is shaped as the server accepts one. + public static boolean nonceShaped(String nonce) { + if (nonce == null || nonce.length() < 16 || nonce.length() > 64) { + return false; + } + for (int iter = 0; iter < nonce.length(); iter++) { + char c = nonce.charAt(iter); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '-' || c == '_'; + if (!ok) { + return false; + } + } + return true; + } + + /// Lower-case hex. + public static String hex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + for (int iter = 0; iter < bytes.length; iter++) { + out[iter * 2] = HEX[(bytes[iter] >> 4) & 0xf]; + out[iter * 2 + 1] = HEX[bytes[iter] & 0xf]; + } + return new String(out); + } +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/StatementLineDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/StatementLineDto.java new file mode 100644 index 00000000000..85e8e3ca379 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/StatementLineDto.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// One line of a balance sheet or income statement: `section` is ASSETS, +/// LIABILITIES, EQUITY, REVENUE or EXPENSES, `amount` is on the normal side in +/// the asset's ledger units and `usdCents` is that amount valued in US cents +/// at the current index price. +@Mapped +public record StatementLineDto( + String section, + String code, + String name, + String asset, + long amount, + long usdCents) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/StatementTotalDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/StatementTotalDto.java new file mode 100644 index 00000000000..2a73059f9f6 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/StatementTotalDto.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A statement subtotal in one asset: `section` as on the lines, plus +/// CURRENT_EARNINGS (revenue less expenses not yet closed) on a balance sheet +/// and NET_INCOME on an income statement. +@Mapped +public record StatementTotalDto( + String asset, + String section, + long amount, + long usdCents) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TAccountDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TAccountDto.java new file mode 100644 index 00000000000..8a3b1685b40 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TAccountDto.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// One account as a T: its debits on the left, credits on the right, between +/// the balance brought forward at `from` and the balance carried at `to` +/// (both on the normal side). The sides list at most the latest 2000 +/// postings and `truncated` says when there were more; the totals and the +/// balances cover every posting in the window. +@Mapped +public record TAccountDto( + String accountId, + String code, + String name, + String accountClass, + String normalBalance, + String asset, + long from, + long to, + long openingBalance, + List debits, + List credits, + long totalDebits, + long totalCredits, + long closingBalance, + boolean truncated) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TickerDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TickerDto.java new file mode 100644 index 00000000000..9f0d40a4eaa --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TickerDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// The simulated price of a market and the best of its book; 0 where a side +/// is empty or nothing has traded. +@Mapped +public record TickerDto(String market, long indexPrice, long lastPrice, long bestBid, long bestAsk, long at) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpCodeDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpCodeDto.java new file mode 100644 index 00000000000..27c5d553449 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpCodeDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A six-digit code from the user's authenticator app. Confirming a new app +/// also carries the account password when the sign-in was made with the +/// password alone (see [TotpStartDto]); otherwise it is null. +@Mapped +public record TotpCodeDto(String code, String password) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpEnrollmentDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpEnrollmentDto.java new file mode 100644 index 00000000000..3fe97220f93 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpEnrollmentDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// What an authenticator app needs: the secret in Base32 for typing in by +/// hand, and the `otpauth://` address a QR code carries. Shown once. +@Mapped +public record TotpEnrollmentDto(String secret, String otpauthUri) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpStartDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpStartDto.java new file mode 100644 index 00000000000..662e00ac9f8 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpStartDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Starts adding an authenticator app. Enrolling one is a step-up, so the +/// account password goes with it whenever the sign-in was made with the +/// password alone (the token's `amr` holds no `mfa`, `otp` or `hwk`): a stolen +/// token, however fresh, must not put the thief's app on the account. +@Mapped +public record TotpStartDto(String password) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TradeDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TradeDto.java new file mode 100644 index 00000000000..51f07e0bbea --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TradeDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// A fill of one of the caller's orders, from the caller's side. +@Mapped +public record TradeDto(String id, String market, String side, boolean taker, long price, long quantity, long quote, long fee, long createdAt) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TransferDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TransferDto.java new file mode 100644 index 00000000000..7e02642365e --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TransferDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Moves `amount` out of `fromAccountId`: into `toAccountId` when it is the +/// caller's own account, or to the checking account of `payeeId`'s customer. +/// Exactly one of the two is set. +@Mapped +public record TransferDto(String fromAccountId, String toAccountId, String payeeId, long amount, String memo) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TrialBalanceDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TrialBalanceDto.java new file mode 100644 index 00000000000..e7f926863fb --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TrialBalanceDto.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// A trial balance as of `asOf`, per asset. +@Mapped +public record TrialBalanceDto( + long asOf, + List lines, + List totals, + boolean balanced) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TrialBalanceLineDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TrialBalanceLineDto.java new file mode 100644 index 00000000000..e7a08ed52c8 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TrialBalanceLineDto.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// One general-ledger account in one asset on a trial balance: its net +/// balance in the debit or the credit column. +@Mapped +public record TrialBalanceLineDto( + String code, + String name, + String accountClass, + String asset, + long debit, + long credit) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/UserDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/UserDto.java new file mode 100644 index 00000000000..5746bd433a3 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/UserDto.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// An account as its owner sees it. +/// +/// @param totpEnabled whether signing in asks for a code from an +/// authenticator app as well as the password +/// @param recoveryCodesLeft how many unused recovery codes the account has; +/// the codes themselves are shown once, when they are made +@Mapped +public record UserDto( + String username, + String displayName, + boolean customer, + boolean admin, + boolean totpEnabled, + int recoveryCodesLeft, + long createdAt, + boolean auditor) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/VerificationDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/VerificationDto.java new file mode 100644 index 00000000000..bf2bcf74270 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/VerificationDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// The result of checking the books or the audit chain: `intact`, or the +/// problems found, each naming what does not hold. +@Mapped +public record VerificationDto(boolean intact, List problems) { +} diff --git a/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/VolumeDayDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/VolumeDayDto.java new file mode 100644 index 00000000000..29f03ce2c16 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/VolumeDayDto.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.bastion.api; + +import com.codename1.annotations.Mapped; + +/// Ledger activity on the UTC day starting at `day` in one asset: the +/// entries that moved it and their total debits. An entry that moves two +/// assets -- a trade -- counts in both rows, so `dayEntries` is the number of +/// distinct entries that day across every asset, the same on each of the +/// day's rows; a count per day reads it once rather than summing `entries`. +@Mapped +public record VolumeDayDto( + long day, + String asset, + int entries, + int dayEntries, + long volume) { +} diff --git a/scripts/bastion/web-e2e.mjs b/scripts/bastion/web-e2e.mjs new file mode 100644 index 00000000000..8add6e94cc3 --- /dev/null +++ b/scripts/bastion/web-e2e.mjs @@ -0,0 +1,915 @@ +// The Bastion web app, hosted by its own server, driven in a headless browser. +// +// URL=http://localhost:18091/ OUT=target/web-e2e node web-e2e.mjs +// +// It checks what a person opening the server's address gets: the app loads from +// the server, with no error, and shows its sign-in form with the way to open an +// account. Then, all through the app's own screens and the authorization-code +// flow the app runs from the page (no Cookie header, the browser's own jar): +// it opens an account, turns on the authenticator app with the key it is shown, +// signs in again in a fresh browser with the password and a six-digit code, +// which the server refuses because the account has a passkey, then with the passkey, +// reaches the dashboard, adds money, opens a second account and moves money +// between the two. BASTION_WEB_ACCOUNTS=0 stops after the sign-in form. +// run-web-e2e.sh builds everything, starts the server and runs this; see there. +// +// The app draws on a canvas, so there are no buttons in the page to ask a +// browser automation tool for. What there is, is the accessibility tree the +// port keeps over the canvas -- an element per component, with its role, its +// label and where it is -- and that is what this reads to find a button and +// what it clicks at. Text fields become real elements while they are +// being edited, so typing is ordinary typing. + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +// require() and not import: it also looks in NODE_PATH, which is how a +// container whose packages are installed somewhere else finds Playwright. +const require = createRequire(import.meta.url); +let chromium; +try { + ({ chromium } = require('playwright')); +} catch (err) { + console.error('[bastion-web] Playwright is not installed. Run `npm ci` in scripts/ and ' + + '`npx playwright install chromium`, or point NODE_PATH at a node_modules that has it.'); + console.error(String(err)); + process.exit(2); +} + +const url = process.env.URL; +const out = process.env.OUT || 'target/web-e2e'; +const accounts = process.env.BASTION_WEB_ACCOUNTS !== '0'; +// The guides' screenshots; needs the development profile's demo accounts. +const tour = process.env.BASTION_WEB_TOUR || ''; +// A new address each run: the server under test may have kept the last one. +const email = `web-${Date.now()}@bastion.example`; +const password = 'correct horse battery staple'; +// A cold runner translates nothing at this point, but it does parse several +// megabytes of JavaScript before the first screen. +const startSeconds = Number(process.env.BASTION_WEB_START_SECONDS || '120'); +const stepSeconds = Number(process.env.BASTION_WEB_STEP_SECONDS || '45'); +if (!url) { + console.error('[bastion-web] URL is required'); + process.exit(2); +} +fs.mkdirSync(out, { recursive: true }); +const logFile = path.join(out, 'browser.log'); +fs.writeFileSync(logFile, ''); +const note = line => fs.appendFileSync(logFile, line + '\n'); +const say = line => { console.log('[bastion-web] ' + line); note('# ' + line); }; + +const origin = new URL(url).origin; +let serverDate = null; // the last Date header the server sent +const requests = []; // "200 POST /login", in order +const problems = []; // anything that must not happen +const sockets = []; // { url, frames: [...] } + +const browser = await chromium.launch({ + headless: true, + // A headless page counts as hidden, and a hidden page's timers are throttled + // to about one a minute once its budget is spent. The app's threads sleep on + // those timers. + args: ['--disable-background-timer-throttling', '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', '--disable-features=IntensiveWakeUpThrottling'] +}); +let page; +let cdp; +let authenticator; +/// A page in a browser of its own: no cookie and no storage from the page +/// before. It has a platform authenticator -- Chromium's virtual one, through +/// the DevTools protocol -- holding whatever passkeys the last one held, the +/// way a phone keeps its passkeys while the browser forgets its session. +async function newPage(options = {}) { + let passkeys = []; + if (page) { + passkeys = (await cdp.send('WebAuthn.getCredentials', { authenticatorId: authenticator })) + .credentials; + await page.context().close(); + } + const context = await browser.newContext({ viewport: options.viewport || { width: 420, height: 860 }, + colorScheme: options.colorScheme || 'light' }); + page = await context.newPage(); + cdp = await context.newCDPSession(page); + await cdp.send('WebAuthn.enable'); + ({ authenticatorId: authenticator } = await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, + hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } + })); + for (const credential of passkeys) { + await cdp.send('WebAuthn.addCredential', { authenticatorId: authenticator, credential }); + } + watch(page); +} + +function watch(page) { +page.on('console', message => { + const text = message.text(); + if (text.startsWith('LF-SHIM')) { + return; + } + note(`[${message.type()}] ${text}`); + if (/internal application error|Missing JS member/i.test(text)) { + problems.push('the app reported an error: ' + text.split('\n')[0]); + } +}); +page.on('pageerror', err => { + note('[pageerror] ' + err); + problems.push('the page threw: ' + err); +}); +page.on('response', response => { + const at = new URL(response.url()); + if (at.origin !== origin) { + return; + } + const line = `${response.status()} ${response.request().method()} ${at.pathname}`; + requests.push(line); + // The server's clock, to show skew when a code is refused. + const date = response.headers()['date']; + if (date) { + serverDate = { date, seen: Date.now() }; + } + // The query says where a list was asked for; it holds no secrets (a token + // travels in a header). + note('NET ' + line + (at.pathname.startsWith('/api/') ? at.search : '')); +}); +page.on('websocket', socket => { + const seen = { url: socket.url(), frames: [] }; + sockets.push(seen); + note('WS open ' + socket.url().replace(/ticket=.*/, 'ticket=...')); + socket.on('framereceived', frame => { + seen.frames.push(String(frame.payload)); + note('WS frame ' + String(frame.payload).slice(0, 200)); + }); +}); +} + +/// What the app has on screen, as the port describes it to assistive technology. +async function components() { + return page.evaluate(() => Array.from( + document.querySelectorAll('#cn1-accessibility-tree [data-cn1-accessibility-id]')).map(el => { + const box = el.getBoundingClientRect(); + return { + role: el.getAttribute('role'), + readonly: el.getAttribute('aria-readonly') === 'true', + label: el.getAttribute('aria-label'), + text: el.innerText, + x: box.x, y: box.y, width: box.width, height: box.height + }; + })); +} + +async function screenText() { + return page.evaluate(() => document.body.innerText); +} + +async function waitFor(what, seconds, probe) { + const end = Date.now() + seconds * 1000; + for (;;) { + const hit = await probe(); + if (hit) { + return hit; + } + if (problems.length > 0) { + throw new Error(problems[0]); + } + if (Date.now() > end) { + throw new Error(`timed out after ${seconds}s waiting for ${what}`); + } + await page.waitForTimeout(250); + } +} + +const button = label => async () => + (await components()).find(c => c.role === 'button' && c.label === label); + +/// The single-line fields of the form on screen, top to bottom. +async function fields() { + // A wrapping heading (a SpanLabel) is a read-only text area: not a field. + return (await components()).filter(c => c.role === 'textbox' && !c.readonly + && c.height > 44 && c.height < 66) + .sort((a, b) => a.y - b.y); +} + +async function click(component) { + // Below the fold (the dashboard's charts push the account list down): + // wheel the form until the component is on screen, then aim again. + const size = page.viewportSize(); + for (let turn = 0; turn < 12 + && (component.y + component.height > size.height || component.y < 0); turn++) { + const name = component.label || component.text; + const below = component.y + component.height - size.height + 80; + await page.mouse.move(size.width * 0.6, size.height / 2); + await page.mouse.wheel(0, component.y < 0 ? Math.max(-size.height * 0.6, component.y - 120) + : Math.min(size.height * 0.6, below)); + await settled(); + const again = (await components()).find(c => (c.label || c.text) === name); + if (!again) break; + component = again; + } + await page.mouse.click(component.x + component.width / 2, component.y + component.height / 2); +} + +/// Waits until nothing on screen has moved for a moment. A form slides in, and +/// a click aimed at where a field is half-way through lands on what was there. +async function settled() { + let before = JSON.stringify(await components()); + await waitFor('the screen to stop moving', stepSeconds, async () => { + await page.waitForTimeout(350); + const now = JSON.stringify(await components()); + const still = now === before; + before = now; + return still; + }); +} + +async function typeInto(index, text, type) { + const editing = () => page.evaluate( + wanted => document.activeElement && (wanted === 'any' + ? /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName) + : document.activeElement.tagName === 'INPUT' && document.activeElement.type === wanted), + type); + await waitFor(`the ${type} field to take the keyboard`, stepSeconds, async () => { + await settled(); + const all = await fields(); + if (all.length <= index) { + return false; + } + await click(all[index]); + await page.waitForTimeout(500); + return editing(); + }); + // Checked, not assumed: on a slow runner a keystroke that lands while the + // port is still attaching its input was seen to arrive twice (a 7-digit + // "six-digit code"). Select what is there and type it again until the field + // holds exactly the text. + for (let attempt = 0; ; attempt++) { + await page.keyboard.type(text, { delay: 10 }); + // The doubled keystroke can also land a moment AFTER typing ends (CI saw + // "25.000" committed while an immediate read-back held "25.00"), so let + // the input settle before trusting what it holds. + await page.waitForTimeout(400); + const value = await page.evaluate(() => document.activeElement && document.activeElement.value); + if (value === text || value === undefined || value === null || attempt >= 2) { + break; + } + // Erased key by key: select() does not apply to a number input. + await page.keyboard.press('End'); + for (let i = 0; i < String(value).length; i++) { + await page.keyboard.press('Backspace'); + } + } +} + +/// Fills in the sign-in form, which is the first screen, and sends it. +async function signInAs(who) { + await waitFor('the sign-in form', startSeconds, button('Sign in')); + await typeInto(0, who, 'email'); + await typeInto(1, password, 'password'); + await settled(); + await click(await waitFor('the Sign in button', stepSeconds, button('Sign in'))); + say('submitted the sign-in form as ' + who); +} + +async function press(label, seconds = stepSeconds) { + // A field still being edited commits on the first click elsewhere, and + // the button it enables would not take that click: end the edit first, on + // the title bar, which does nothing. + if (await page.evaluate(() => document.activeElement + && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName))) { + await page.mouse.click(210, 8); + } + await settled(); + await click(await waitFor(`the ${label} button`, seconds, button(label))); +} + +/// RFC 6238 with the authenticator-app defaults: SHA-1, six digits, 30 s. +function totp(secret, step) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = ''; + for (const c of secret.replace(/[\s=]/g, '').toUpperCase()) { + bits += alphabet.indexOf(c).toString(2).padStart(5, '0'); + } + const key = Buffer.alloc(Math.floor(bits.length / 8)); + for (let i = 0; i < key.length; i++) { + key[i] = parseInt(bits.substr(i * 8, 8), 2); + } + const counter = Buffer.alloc(8); + counter.writeBigUInt64BE(BigInt(step)); + const mac = crypto.createHmac('sha1', key).update(counter).digest(); + const at = mac[mac.length - 1] & 0xf; + return String((mac.readUInt32BE(at) & 0x7fffffff) % 1000000).padStart(6, '0'); +} +const step = () => Math.floor(Date.now() / 30000); + +/// 20 random bytes, as TotpService makes them: 32 base32 characters. +const SECRET_LENGTH = 32; +const validSecret = s => typeof s === 'string' && s.length === SECRET_LENGTH + && /^[A-Z2-7]+$/.test(s); + +/// What a refused code needs to explain itself, never the secret: its length +/// and a hash prefix (to tell two scrapes apart), the steps on both clocks +/// and the requests the code went out with. +async function codeDiagnostics(where, secret, usedStep) { + const fingerprint = crypto.createHash('sha256').update(secret).digest('hex').slice(0, 8); + let server = 'unknown'; + try { + const head = await fetch(url, { method: 'HEAD' }); + const date = head.headers.get('date'); + if (date) { + const skew = Date.parse(date) - Date.now(); + server = `${date} (skew ~${Math.round(skew / 1000)}s, 1s resolution)`; + } + } catch (e) { + server = serverDate ? `${serverDate.date} (seen ${Date.now() - serverDate.seen}ms ago)` + : 'unreachable: ' + e; + } + say(`code refused at ${where}: test step ${usedStep}, step now ${step()}, ` + + `ms into step ${Date.now() % 30000}, secret length ${secret.length} ` + + `fingerprint ${fingerprint}, server time ${server}`); + say('last requests: ' + requests.slice(-12).join(', ')); +} + +/// The key on screen, re-read until it is a whole, valid secret: a label +/// caught mid-layout or a different text matching the grouping is not one. +async function scrapeSecret() { + let last = ''; + const secret = await waitFor('a valid authenticator key', stepSeconds, async () => { + // The key is shown as boxes of four (Ui.keyGroups), wrapping: read the + // boxes between its heading and the Copy key button, in reading order. + const all = await components(); + const text = c => (c.label || c.text || '').trim(); + const heading = all.find(c => /^Or enter this key$/.test(text(c))); + const copy = all.find(c => /^Copy key$/.test(text(c))); + if (!heading || !copy) { + return null; + } + last = all.filter(c => c.y > heading.y && c.y < copy.y && /^[A-Z2-7]{1,4}$/.test(text(c))) + .sort((a, b) => (a.y - b.y) || (a.x - b.x)).map(text).join(''); + return validSecret(last) ? last : null; + }).catch(e => { + throw new Error(`${e.message}; last candidate had ${last.length} characters`); + }); + return secret; +} + +/// Waits for the dashboard after a code, explaining a refusal if one shows. +async function dashboardAfterCode(where, secret, usedStep) { + try { + await waitFor('the dashboard', stepSeconds, async () => { + const text = await screenText(); + if (/not right|not the one your app shows/.test(text)) { + throw new Error('the code was refused'); + } + return /Total balance/.test(text); + }); + } catch (e) { + await codeDiagnostics(where, secret, usedStep); + throw e; + } +} + +/// Fails on the dialog the app shows for an error nothing handled. +async function noErrorDialog() { + if (/internal application error/i.test(await screenText())) { + problems.push('the app is showing its internal-error dialog'); + } + if (problems.length > 0) { + throw new Error(problems[0]); + } +} + +/// Opens the side menu and picks `label` in it. +async function menu(label) { + for (let tries = 0; tries < 6; tries++) { + await settled(); + // The menu button is the right-most of the title bar's left buttons: a + // form below the dashboard has its back button before it. + const open = (await components()).filter(c => c.role === 'button' && c.y < 70 && c.x < 130) + .sort((x, y) => y.x - x.x)[0]; + if (!open) { + // The Security Center has its way back at the end of the page. + const back = await button('Back')(); + if (!back) { + throw new Error('no menu button and no Back button on screen'); + } + if (back.y > 800) { + await page.mouse.move(210, 430); + await page.mouse.wheel(0, 6000); + continue; + } + await click(back); + continue; + } + await click(open); + await settled(); + // The menu slides in; give its commands a moment to reach the tree. + let item = null; + for (let wait = 0; wait < 10 && !item; wait++) { + item = await button(label)(); + if (!item) { + await page.waitForTimeout(300); + } + } + if (item) { + await click(item); + await settled(); + return; + } + } + throw new Error('no ' + label + ' in the side menu'); +} + +async function shot(name) { + await settled(); + // A screen still loading is never a picture of the screen: wait the + // placeholder out (Ui.loading captions it "Loading..."). + await waitFor(name + ' to finish loading', stepSeconds, + async () => !/Loading\.\.\./.test(await screenText())); + await settled(); + await noErrorDialog(); + await page.screenshot({ path: path.join(tour, name + '.png') }); + say('took ' + name); +} + +const textOnScreen = pattern => async () => pattern.test(await screenText()); + +/// The back arrow of a pushed screen: the only button at the start of its +/// title bar. +async function back() { + await settled(); + const arrow = (await components()).filter(c => c.role === 'button' && c.y < 70 && c.x < 130) + .sort((x, y) => x.x - y.x)[0]; + if (!arrow) { + throw new Error('no back arrow on screen'); + } + await click(arrow); + await settled(); +} + +/// Something on screen whose text matches, inside the viewport. +const visible = (pattern, top = 60) => async () => { + const height = page.viewportSize().height; + return (await components()).find(c => pattern.test(c.label || c.text || '') + && c.y > top && c.y + c.height < height && c.height < 200); +}; + +async function scroll(dy) { + const size = page.viewportSize(); + await page.mouse.move(size.width * 0.6, size.height / 2); + await page.mouse.wheel(0, dy); + await settled(); +} + +/// DemoAccounts.STAFF_TOTP_SECRET: staff hold their role only after a sign-in +/// with a second factor, so the demo staff have this authenticator. +const STAFF_TOTP_SECRET = 'BASTIONDEMOSTAFFAUTHENTICATOR234'; +/// The last step each staff member's code went out in: a code is good once. +const staffSteps = {}; + +async function signInStaff(who, viewport) { + await newPage({ viewport }); + await page.goto(url, { waitUntil: 'load' }); + await waitFor('the sign-in form', startSeconds, button('Sign in')); + await typeInto(0, who, 'email'); + await typeInto(1, 'bastion-demo-password', 'password'); + await press('Sign in'); + await waitFor('the code screen', stepSeconds, button('Continue')); + // The server takes the step before and after the current one too. + let at = Math.max(step() - 1, (staffSteps[who] ?? -Infinity) + 1); + if (at > step() + 1) { + await waitFor('a new code', 35, async () => step() + 1 >= at); + } + staffSteps[who] = at; + await typeInto(0, totp(STAFF_TOTP_SECRET, at), 'any'); + await page.mouse.click(210, 8); + await settled(); + await press('Continue'); +} + +/// The screens the guides show, on the development profile's demo accounts: +/// BASTION_WEB_TOUR names the directory the pictures go to. +async function guideTour() { + fs.mkdirSync(tour, { recursive: true }); + for (const [from, to] of [['1-sign-in', 'bastion-sign-in'], + ['2-authenticator', 'bastion-totp-enrolment'], ['3-code', 'bastion-sign-in-code'], + ['3-passkey-required', 'bastion-sign-in-passkey-required']]) { + if (fs.existsSync(path.join(out, from + '.png'))) { + fs.copyFileSync(path.join(out, from + '.png'), path.join(tour, to + '.png')); + } + } + await newPage(); + await page.goto(url, { waitUntil: 'load' }); + await waitFor('the sign-in form', startSeconds, button('Sign in')); + await typeInto(0, 'customer@bastion.example', 'email'); + await typeInto(1, 'bastion-demo-password', 'password'); + await press('Sign in'); + await waitFor('the dashboard', stepSeconds, textOnScreen(/Total balance/)); + await shot('bastion-dashboard-unregistered'); + // A password-only sign-in registers no device: the app says to set up an + // authenticator app or a passkey first, and the dashboard stays as it is. + await press('Register this device'); + await waitFor('the second-factor prompt', stepSeconds, + textOnScreen(/Set up a second factor/)); + await press('Not now'); + await settled(); + await waitFor('the dashboard charts', stepSeconds, textOnScreen(/Where your money is/)); + await page.waitForTimeout(1500); + await shot('bastion-dashboard'); + // The Initializr's preview of the template: the dashboard at the size of + // the other templates' previews. + await page.setViewportSize({ width: 562, height: 1217 }); + await page.waitForTimeout(2500); + await settled(); + await page.screenshot({ path: path.join(out, 'bastion-preview.png') }); + await page.setViewportSize({ width: 420, height: 860 }); + await page.waitForTimeout(1500); + await settled(); + + const checking = await waitFor('the checking account', stepSeconds, async () => + (await components()).find(c => /Checking/.test(c.label || c.text || '') && c.height < 200)); + await click(checking); + await waitFor('the history', stepSeconds, textOnScreen(/Money in/)); + await waitFor('the charts', stepSeconds, async () => + requests.some(r => /200 GET \/api\/insights\/cashflow/.test(r))); + await page.waitForTimeout(1500); + await shot('bastion-account-history'); + await press('Statement'); + await waitFor('the statement', stepSeconds, textOnScreen(/Opening balance/)); + await shot('bastion-statement'); + await back(); + await waitFor('the account', stepSeconds, textOnScreen(/Order a card/)); + await page.screenshot({ path: path.join(out, 'after-statement.png') }); + await scroll(1500); + await click(await waitFor('a history row', stepSeconds, visible(/^Money in$/))); + await waitFor('the transaction', stepSeconds, textOnScreen(/Balance after/)); + await shot('bastion-transaction'); + await back(); + await waitFor('the account', stepSeconds, textOnScreen(/Order a card/)); + await scroll(-6000); + await press('Order a card'); + await waitFor('the card', stepSeconds, async () => requests.some(r => /POST \/api\/accounts\/.*\/card/.test(r))); + await waitFor('the cards screen', stepSeconds, textOnScreen(/Daily limit/)); + await waitFor('the card payments', stepSeconds, textOnScreen(/Recent card payments/)); + await page.waitForTimeout(1000); + await shot('bastion-cards'); + + await menu('Payees'); + await press('Add a payee'); + await typeInto(0, 'customer2@bastion.example', 'email'); + await typeInto(1, 'Robin', 'any'); + await press('Add'); + await waitFor('the payee', stepSeconds, textOnScreen(/Cooling off/)); + await shot('bastion-payees'); + + await menu('Accounts'); + await press('Pay someone'); + await typeInto(0, '20.00', 'any'); + await page.mouse.click(210, 8); + await shot('bastion-transfer'); + await press('Review'); + await waitFor('the confirmation', stepSeconds, textOnScreen(/Send .* to /)); + await shot('bastion-transfer-confirm'); + await press('Send'); + await waitFor('the receipt', stepSeconds, textOnScreen(/Sent/)); + // The balance it left arrives after the receipt is up. + await page.waitForTimeout(2000); + await shot('bastion-receipt'); + // A receipt is the end of a flow: it closes back to where it started. + await press('Done'); + await waitFor('the dashboard', stepSeconds, textOnScreen(/Total balance/)); + + + await menu('Exchange'); + await waitFor('the markets', stepSeconds, textOnScreen(/BTC \/ USD/)); + await shot('bastion-exchange'); + const btc = await waitFor('the BTC market', stepSeconds, async () => + (await components()).find(c => /^BTC \/ USD$/.test(c.label || c.text || '') + && c.height < 200)); + await click(btc); + await waitFor('the order book', stepSeconds, textOnScreen(/Last price/)); + await page.waitForTimeout(3000); + await shot('bastion-order-book'); + // A market buy: the wallet it fills is what Send crypto sends from. + await press('Market'); + await page.mouse.move(210, 430); + await page.mouse.wheel(0, 600); + await typeInto(0, '100.00', 'any'); + await press('Place order'); + await press('Place'); + await waitFor('the fill', stepSeconds, async () => requests.includes('200 POST /api/exchange/orders')); + await page.waitForTimeout(1500); + await shot('bastion-order-filled'); + + await menu('Send crypto'); + await press('Add an address'); + await typeInto(0, 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', 'any'); + await typeInto(1, 'Hardware wallet', 'any'); + await press('Add'); + await press('OK'); + await shot('bastion-withdraw'); + + await menu('Security Center'); + await shot('bastion-security-center'); + + await menu('Settings'); + await shot('bastion-settings'); + await click(await waitFor('the Unlock PIN row', stepSeconds, async () => + (await components()).find(c => c.label === 'Unlock PIN'))); + await typeInto(0, '480261', 'any'); + await typeInto(1, '480261', 'any'); + await press('Set PIN'); + await waitFor('the PIN to be on', stepSeconds, textOnScreen(/Opens the lock/)); + // Two minutes without a touch lock the app (AppConfig.IDLE_LOCK_MILLIS). + await waitFor('the idle lock', 200, textOnScreen(/Bastion is locked/)); + await page.waitForTimeout(1500); + await shot('bastion-lock'); + + // The customer's dashboard in dark mode. + await newPage({ colorScheme: 'dark' }); + await page.goto(url, { waitUntil: 'load' }); + await waitFor('the sign-in form', startSeconds, button('Sign in')); + await typeInto(0, 'customer2@bastion.example', 'email'); + await typeInto(1, 'bastion-demo-password', 'password'); + await press('Sign in'); + await waitFor('the dashboard', stepSeconds, textOnScreen(/Total balance/)); + await waitFor('the allocation', stepSeconds, textOnScreen(/Where your money is/)); + await page.waitForTimeout(1500); + await shot('bastion-dashboard-dark'); + + // The back office, on a desktop-sized window. + const desk = { width: 1440, height: 900 }; + await signInStaff('admin@bastion.example', desk); + await waitFor('the console', stepSeconds, textOnScreen(/Awaiting approval/)); + await waitFor('the volume charts', stepSeconds, async () => + requests.some(r => /200 GET \/api\/books\/volume/.test(r))); + await press('Verify the audit log'); + await press('Verify the ledger'); + await waitFor('the verification', stepSeconds, textOnScreen(/Intact|Problems found/)); + await page.waitForTimeout(1500); + await shot('bastion-console-overview'); + await press('Journal'); + await waitFor('the journal', stepSeconds, textOnScreen(/Tap an entry/)); + await shot('bastion-console-journal'); + await click(await waitFor('an entry', stepSeconds, visible(/^Order hold/, 150))); + await waitFor('the entry', stepSeconds, textOnScreen(/Journal entry/)); + await shot('bastion-console-entry'); + for (const [section, pattern, name] of [ + ['Trial balance', /Class/, 'bastion-console-trial-balance'], + ['Balance sheet', /Total assets/, 'bastion-console-balance-sheet'], + ['Income statement', /Net income/, 'bastion-console-income-statement'], + ['Reconciliation', /Subledger/, 'bastion-console-reconciliation'], + ['Period close', /Closed through/, 'bastion-console-periods'], + ['Audit chain', /Links checked/, 'bastion-console-audit-chain']]) { + await press(section); + await waitFor(section, stepSeconds, textOnScreen(pattern)); + await page.waitForTimeout(500); + await shot(name); + } + await press('General ledger'); + // Maker: an adjustment over the four-eyes threshold waits for a second + // administrator. + await press('Raise an adjustment'); + await typeInto(0, '1500.00', 'any'); + await typeInto(1, 'Goodwill credit, case 4471', 'any'); + await typeInto(2, 'bastion-demo-password', 'password'); + await press('Request adjustment'); + await waitFor('the request', stepSeconds, async () => + requests.some(r => /POST \/api\/admin\/adjust/.test(r))); + // Let the "Sent for approval" notice go before the next screenshot. + await page.waitForTimeout(4000); + await settled(); + await press('Show as a T-account'); + await waitFor('the T-account', stepSeconds, textOnScreen(/Total debits/)); + await shot('bastion-console-t-account'); + await press('Evidence'); + await click(await waitFor('the CSV button', stepSeconds, button('CSV'))); + await waitFor('the evidence', stepSeconds, textOnScreen(/Saved/)); + await shot('bastion-console-evidence'); + + // Checker: a second administrator decides. + await signInStaff('admin2@bastion.example', desk); + await waitFor('the console', stepSeconds, textOnScreen(/Awaiting approval/)); + await press('Approvals'); + await waitFor('the queue', stepSeconds, textOnScreen(/Maker-checker/)); + await shot('bastion-console-approvals'); + await click(await waitFor('the pending reversal', stepSeconds, visible(/^Pending$/, 150))); + await typeInto(0, 'Checked against the customer file', 'any'); + await typeInto(1, 'bastion-demo-password', 'password'); + await shot('bastion-console-approve'); + await press('Approve'); + await waitFor('the approval', stepSeconds, async () => + requests.some(r => /200 POST \/api\/admin\/approvals\/.*\/approve/.test(r))); + + // The auditor reads everything and changes nothing. + await signInStaff('auditor@bastion.example', desk); + await waitFor('the console', stepSeconds, textOnScreen(/Read-only/)); + await press('Access log'); + await waitFor('the access log', stepSeconds, textOnScreen(/Staff member/)); + await page.waitForTimeout(500); + await shot('bastion-console-auditor'); + // And on a phone the same console is one column. + await signInStaff('auditor@bastion.example', undefined); + await waitFor('the console', stepSeconds, textOnScreen(/Read-only/)); + await page.waitForTimeout(1500); + await shot('bastion-console-phone'); +} + +let failed = null; +try { + say('opening ' + url); + await newPage(); + const first = await page.goto(url, { waitUntil: 'load' }); + if (!first || first.status() !== 200) { + throw new Error('the server answered ' + (first ? first.status() : 'nothing') + ' at /'); + } + const type = first.headers()['content-type'] || ''; + if (!type.startsWith('text/html')) { + throw new Error('/ is served as ' + type + ', not as a page'); + } + + // 1. The sign-in form: the app started, in the browser, from this server. + await waitFor('the sign-in form', startSeconds, button('Sign in')); + await waitFor('the form to offer an account', stepSeconds, button('Open an account')); + await noErrorDialog(); + await page.screenshot({ path: path.join(out, '1-sign-in.png') }); + say('the sign-in form is up'); + for (const file of ['/translated_app.js', '/port.js', '/worker.js']) { + if (!requests.includes('200 GET ' + file)) { + throw new Error(file + ' was not served by this server: ' + requests.join(', ')); + } + } + + if (accounts) { + // 2. Opening an account: the app registers, then signs in from the page. + await press('Open an account'); + await typeInto(0, 'Web Tester', 'any'); + await typeInto(1, email, 'email'); + await typeInto(2, password, 'password'); + await typeInto(3, password, 'password'); + await press('Create account'); + say('asked for an account as ' + email); + + // 3. The authenticator app: the sign-in was made with the password alone, + // so adding an app asks for it again. The key on screen is then what an + // app would scan. + await waitFor('the password prompt', stepSeconds, async () => + /Confirm it is you/.test(await screenText())); + await typeInto(0, password, 'password'); + await press('Continue'); + const secret = await scrapeSecret(); + await page.screenshot({ path: path.join(out, '2-authenticator.png') }); + const confirmedAt = step(); + await typeInto(0, totp(secret, confirmedAt), 'any'); + await press('Turn on'); + try { + await waitFor('the recovery codes', stepSeconds, async () => { + if (/not the one your app shows/.test(await screenText())) { + throw new Error('the enrolment code was refused'); + } + return button('I have saved them')(); + }); + } catch (e) { + await codeDiagnostics('enrolment', secret, confirmedAt); + throw e; + } + await press('I have saved them'); + say('turned the authenticator app on'); + // A passkey, made in the browser with the session the sign-in kept. + await press('Create a passkey'); + await waitFor('the passkey to be saved', stepSeconds, async () => + requests.includes('200 POST /webauthn/register')); + say('created a passkey'); + // Biometrics and this device are offered next; the first is missing in a + // headless browser, and the device is registered later, after a passkey + // sign-in. + await waitFor('the rest of onboarding', stepSeconds, async () => + (await button('Continue')()) || (await button('Not now')()) + || (await button('Later')()) || /Total balance/.test(await screenText())); + for (const next of ['Continue', 'Not now', 'Later']) { + await settled(); + const hit = await button(next)(); + if (hit) { + await click(hit); + } + } + await waitFor('the dashboard', stepSeconds, async () => + /Total balance/.test(await screenText())); + await noErrorDialog(); + say('onboarded'); + + // 4. A fresh browser: password, then the code the account asks for. The + // account has a passkey now, so the server holds it to the passkey: the + // app's code is refused (only a recovery code would stand in for the + // passkey), and the passkey on the same screen signs in. + await newPage(); + await page.goto(url, { waitUntil: 'load' }); + await signInAs(email); + await waitFor('the code screen', stepSeconds, button('Continue')); + // The step the code was confirmed in is used up; wait for the next one. + await waitFor('a new code', 35, async () => step() > confirmedAt); + const signInStep = step(); + await typeInto(0, totp(secret, signInStep), 'any'); + await page.mouse.click(210, 8); + await settled(); + await page.screenshot({ path: path.join(out, '3-code.png') }); + await press('Continue'); + await waitFor('the passkey refusal', stepSeconds, async () => + /signs in with its passkey/.test(await screenText())); + await settled(); + await page.screenshot({ path: path.join(out, '3-passkey-required.png') }); + say('an authenticator code was refused for an account with a passkey'); + for (const done of ['302 POST /login', '200 GET /login/mfa', '403 POST /login/mfa']) { + if (!requests.includes(done)) { + throw new Error('the sign-in did not go ' + done + ': ' + requests.join(', ')); + } + } + await press('Sign in with a passkey'); + await waitFor('the dashboard', stepSeconds, async () => + /Total balance/.test(await screenText())); + await noErrorDialog(); + await page.screenshot({ path: path.join(out, '3-home.png') }); + say('signed in with the passkey instead'); + + // 5. A second account, money in, and a move between the two. + await press('Open an account'); + await press('Open'); + await settled(); + const checking = await waitFor('the checking account', stepSeconds, async () => + (await components()).find(c => /Checking/.test(c.label || c.text || '') + && c.height < 200)); + await click(checking); + await press('Add money'); + await typeInto(0, '100.00', 'any'); + await press('Add'); + await waitFor('the deposit', stepSeconds, async () => /100\.00/.test(await screenText())); + say('opened a second account and added 100.00'); + await press('Move money'); + await typeInto(0, '25.00', 'any'); + await press('Review'); + await press('Send'); + await waitFor('the receipt', stepSeconds, async () => /Sent/.test(await screenText())); + await noErrorDialog(); + await page.screenshot({ path: path.join(out, '4-receipt.png') }); + if (!requests.some(line => line === '200 POST /api/transfers')) { + throw new Error('no transfer was made: ' + requests.join(', ')); + } + say('moved 25.00 between own accounts'); + + // 6. A fresh browser, and the passkey alone: no password, no code. The + // token says so (amr "hwk"), and that is enough to register this browser + // as a signing device without typing a password. + await newPage(); + await page.goto(url, { waitUntil: 'load' }); + await press('Sign in with a passkey', startSeconds); + await waitFor('the dashboard', stepSeconds, async () => + /Total balance/.test(await screenText())); + if (!requests.includes('200 POST /login/webauthn')) { + throw new Error('the passkey sign-in did not go through /login/webauthn: ' + + requests.join(', ')); + } + say('signed in with the passkey'); + await press('Register this device'); + await waitFor('the device to be registered', stepSeconds, async () => + requests.includes('200 POST /api/devices')); + await settled(); + await noErrorDialog(); + if (/Confirm it is you/.test(await screenText())) { + throw new Error('a passkey sign-in was asked for its password'); + } + await page.screenshot({ path: path.join(out, '5-device.png') }); + say('registered this browser as a device after a passkey sign-in'); + + // 7. A reload keeps the session: the stored one opens behind the lock + // (there is no PIN or biometric here, so the lock asks for the password) + // instead of the app forgetting it and showing the sign-in form. + await page.reload({ waitUntil: 'load' }); + await waitFor('the lock after a reload', startSeconds, async () => + /Bastion is locked/.test(await screenText())); + await page.screenshot({ path: path.join(out, '6-reload.png') }); + say('a reload found the stored session'); + } + + if (tour) { + await guideTour(); + } +} catch (err) { + failed = err; + try { + await page.screenshot({ path: path.join(out, 'failure.png') }); + fs.writeFileSync(path.join(out, 'failure.txt'), await screenText()); + fs.writeFileSync(path.join(out, 'failure-components.json'), + JSON.stringify(await components(), null, 1)); + } catch (ignored) { + // The page is gone; the log is what there is. + } +} +await browser.close(); + +if (failed) { + console.error('[bastion-web] FAILED: ' + failed.message); + console.error('[bastion-web] the browser log is ' + logFile); + process.exit(1); +} +say('passed'); diff --git a/scripts/check-copyright-headers.sh b/scripts/check-copyright-headers.sh index b5d881db302..b2e59b69840 100755 --- a/scripts/check-copyright-headers.sh +++ b/scripts/check-copyright-headers.sh @@ -38,10 +38,11 @@ is_excluded() { local candidate="$1" # What Initializr puts into a generated project is the developer's, not # Codename One's GPLv2 + Classpath Exception source: these trees are released - # under MIT No Attribution (scripts/wayline/LICENSE, scripts/savor/LICENSE, scripts/skirmish/LICENSE, scripts/parley/LICENSE), and carry that header + # under MIT No Attribution (scripts/wayline/LICENSE, scripts/savor/LICENSE, scripts/skirmish/LICENSE, scripts/parley/LICENSE, + # scripts/bastion/LICENSE), and carry that header # or none. case "$candidate" in - scripts/wayline/*|scripts/savor/*|scripts/skirmish/*|scripts/parley/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; + scripts/wayline/*|scripts/savor/*|scripts/skirmish/*|scripts/parley/*|scripts/bastion/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; esac awk -F '\\|' -v candidate="$candidate" ' /^[[:space:]]*(#|$)/ { next } diff --git a/scripts/copyright-header-exclusions.txt b/scripts/copyright-header-exclusions.txt index eb8430e815a..3503fb02610 100644 --- a/scripts/copyright-header-exclusions.txt +++ b/scripts/copyright-header-exclusions.txt @@ -421,3 +421,8 @@ Ports/CLDC11/src/java/util/Collections.java | Apache Harmony class library sourc vm/JavaAPI/src/java/io/ByteArrayOutputStream.java | Apache Harmony class library source retaining its original Apache-2.0 notice vm/JavaAPI/src/java/lang/CharSequence.java | Apache Harmony class library source retaining its original Apache-2.0 notice vm/JavaAPI/src/java/util/Objects.java | Apache Harmony class library source retaining its original Apache-2.0 notice +CodenameOne/src/com/codename1/util/BigDecimal.java | Bouncy Castle derived source with its original provenance note +CodenameOne/src/com/codename1/util/TBigDecimal.java | Apache Harmony source retaining its original Apache-2.0 notice +CodenameOne/src/com/codename1/util/TBigInteger.java | Apache Harmony source retaining its original Apache-2.0 notice +CodenameOne/src/com/codename1/util/TBitLevel.java | Apache Harmony source retaining its original Apache-2.0 notice +CodenameOne/src/com/codename1/util/TDivision.java | Apache Harmony source retaining its original Apache-2.0 notice diff --git a/scripts/flutter-bench/baseline/pr/6081.json b/scripts/flutter-bench/baseline/pr/6081.json new file mode 100644 index 00000000000..a9c42f43092 --- /dev/null +++ b/scripts/flutter-bench/baseline/pr/6081.json @@ -0,0 +1,20 @@ +{ + "pr": 6081, + "rebaseline": { + "android": { + "gallery": { + "code_bytes": { + "from": { + "tolerance": { + "value": 0.005 + }, + "value": 4854044 + }, + "reason": "Bastion PR adds Android clipboard ownership/deferred-clear handling to the port (+25 KB executable code)", + "runs": 1, + "value": 4878952 + } + } + } + } +} diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java index a06dc8d7d0a..1a6f3e4d94f 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java @@ -467,10 +467,20 @@ private Container createLanguageSelector(Template[] selectedTemplate, Runnable o if (!GeneratorModel.isTemplateOffered(Template.WAYLINE)) { return empty; } - Template[] apps = {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.PARLEY}; - Container complete = new Container(new GridLayout(2, 2)); + Template[] apps = {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.PARLEY, + Template.BASTION}; + int offered = 0; + for (Template app : apps) { + if (GeneratorModel.isTemplateOffered(app)) { + offered++; + } + } + Container complete = new Container(new GridLayout((offered + 1) / 2, 2)); complete.setUIID("InitializrChoicesGrid"); for (Template app : apps) { + if (!GeneratorModel.isTemplateOffered(app)) { + continue; + } addTemplateChoice(complete, group, app, templateTitle(app), selectedTemplate, onSelectionChanged); } SpanLabel hint = new SpanLabel("A complete app is the app, its server and the API module they share, " @@ -501,6 +511,7 @@ private void addTemplateChoice(Container parent, ButtonGroup group, final Templa static String templateTitle(Template template) { return template == Template.SAVOR ? "Food delivery" : template == Template.SKIRMISH ? "Card battle game" : template == Template.PARLEY ? "Messaging" : template == Template.WAYLINE ? "Ride hailing" + : template == Template.BASTION ? "Banking" : template.IS_KOTLIN ? "Empty app, Kotlin" : "Empty app, Java"; } diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index 382b9532505..3fc169861e2 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -80,6 +80,18 @@ public static boolean isMavenLayoutChoiceOffered() { /// neither offered nor generated for a release before this one. static final String FULL_STACK_SINCE = "7.0.277"; + /// The first release whose framework has what the banking template's app calls: + /// timed clipboard clearing, the P-256 key and signature API it signs requests + /// with, and the `BigDecimal` arithmetic its ledger rounds with. Against an + /// older release its download does not compile, so it is neither offered nor + /// generated there. + static final String BASTION_SINCE = "7.0.280"; + + /// The first release a template can be generated against. + static String templateSince(Template template) { + return template == Template.BASTION ? BASTION_SINCE : FULL_STACK_SINCE; + } + /// The brand colour of the full-stack template as it ships: the `--brand` its /// theme.css declares, which is what the UI paints the icon with when no other /// colour is chosen. GeneratorModelMatrixTest holds the two together. @@ -88,6 +100,9 @@ public static boolean isMavenLayoutChoiceOffered() { /// The food-delivery template's `--brand`, held to its theme.css the same way. public static final int SAVOR_BRAND = 0x7ac943; + /// The banking template's `--brand`, held to its theme.css the same way. + public static final int BASTION_BRAND = 0x1f3a8a; + /// The card-battle template's accent, used for its icon: it has no `--brand`. public static final int SKIRMISH_BRAND = 0xc8862f; @@ -100,6 +115,9 @@ public static int brandOf(Template template) { if (template == Template.PARLEY) { return PARLEY_BRAND; } + if (template == Template.BASTION) { + return BASTION_BRAND; + } return template == Template.SAVOR ? SAVOR_BRAND : template == Template.SKIRMISH ? SKIRMISH_BRAND : FULL_STACK_BRAND; } @@ -107,7 +125,7 @@ public static int brandOf(Template template) { /// Whether `template` can be generated at [CN1_PLUGIN_VERSION]: the UI offers /// only the templates this answers true for. public static boolean isTemplateOffered(Template template) { - return !template.isFullStack() || isVersionAtLeast(CN1_PLUGIN_VERSION, FULL_STACK_SINCE); + return !template.isFullStack() || isVersionAtLeast(CN1_PLUGIN_VERSION, templateSince(template)); } /// The plugin version a download is generated against. @@ -974,6 +992,9 @@ void validateOptions() throws IOException { throw new IOException("This template shares a contract module between the app and its server, " + "which needs Codename One " + FULL_STACK_SINCE + " or newer."); } + if (!isVersionAtLeast(pluginVersion, templateSince(template))) { + throw new IOException("This template needs Codename One " + templateSince(template) + " or newer."); + } if (options.projectType != ProjectOptions.ProjectType.APP_WITH_BACKEND) { throw new IOException("This template is an app and its server; choose \"" + ProjectOptions.ProjectType.APP_WITH_BACKEND.label + "\"."); @@ -1847,12 +1868,14 @@ static String applyThemeVariables(String css, ProjectOptions options) throws IOE // washed out to white, and in dark mode barely lifted off the // near-black surface. The stock values are these same mixes of the // stock brand, so every scheme keeps the template's proportions. - css = setCssVariable(css, "--brand-tint", toCssColor(lightenColor(brand, 0.92f))); - css = setCssVariable(css, "--brand-tint-dark", toCssColor(mixColor(DARK_SURFACE, brand, 0.16f))); + // Bastion's theme has no tint (its selected states are the brand + // itself), so these two are set only where the template declares them. + css = setDeclaredCssVariable(css, "--brand-tint", toCssColor(lightenColor(brand, 0.92f))); + css = setDeclaredCssVariable(css, "--brand-tint-dark", toCssColor(mixColor(DARK_SURFACE, brand, 0.16f))); } if (!effective.roundedButtons) { css = setCssVariable(css, "--radius", "0.6mm"); - css = setCssVariable(css, "--radius-small", "0.4mm"); + css = setDeclaredCssVariable(css, "--radius-small", "0.4mm"); } return css; } @@ -1870,6 +1893,13 @@ private static String setCssVariable(String css, String name, String value) thro return css.substring(0, at) + name + ": " + value + css.substring(end); } + /// `css` with `name` given `value` when the stylesheet declares it, and + /// unchanged when it does not: for the variables only some templates have. + /// Everything a scheme always needs goes through [#setCssVariable] instead. + private static String setDeclaredCssVariable(String css, String name, String value) throws IOException { + return css.indexOf(name + ":") < 0 ? css : setCssVariable(css, name, value); + } + /// Near-black or white, whichever reads on `color`. private static int contrastColor(int color) { int r = (color >> 16) & 0xff; @@ -2010,6 +2040,8 @@ private String buildReadmeMarkdown() { .append("On Windows: `run-server.bat`, then `run.bat`. Your IDE's run menu has both: ") .append("\"Run Server\" and the simulator.\n\n") .append("`./mvnw -pl backend -am -Dcodename1.platform=backend test` runs the server's tests.\n\n"); + } else if (template == Template.BASTION) { + appendBankSection(out, toLowerCaseInvariant(appName)); } else if (template.isFullStack()) { boolean food = template == Template.SAVOR; boolean chat = template == Template.PARLEY; @@ -2122,6 +2154,39 @@ private void appendBuildProgressReportingSection(StringBuilder out) { .append(options.isGradle() ? "`gradle.properties`" : "`pom.xml`").append(".\n\n"); } + /// The README section of the banking template: what the project is, how to run + /// it and the accounts its development profile creates. + private static void appendBankSection(StringBuilder out, String lower) { + out.append("## The app and its server\n\n") + .append("This project is a working bank: a customer and an admin mode in one app, chosen by the ") + .append("role of whoever signs in, and the server they talk to.\n\n") + .append("- `shared/` is the contract: the REST interfaces and the objects they carry. The app's ") + .append("client and the server's dispatcher are both generated from it, so the two cannot drift.\n") + .append("- `backend/` is the server: the double-entry ledger, sign-in with a second factor and ") + .append("passkeys, transfers with signed requests and limits, cards, the exchange, the audit chain ") + .append("and the database migrations.\n") + .append("- `common/` is the app. `src/main/css/theme.css` opens with the colours and corner ") + .append("radius every screen takes its look from.\n\n") + .append("Start the server, then, in a second terminal, the app:\n\n```\n") + .append("CN1_PROFILE=dev backend/server.sh run\n") + .append("./run.sh\n```\n\n") + .append("`server.sh` is a few lines of shell around Maven; on Windows run what it runs, ") + .append("with `mvnw.cmd`, or use a Unix shell such as Git Bash or WSL.\n\n") + .append("On the `dev` profile the server creates demonstration accounts, all with the ") + .append("password `").append(lower).append("-demo-password`: `customer@").append(lower) + .append(".example` and `customer2@").append(lower).append(".example`, customers with money in ") + .append("their checking accounts, and `admin@").append(lower).append(".example`. ") + .append("Deposits, card spending and the coins are simulated; the passwords, authenticator codes, ") + .append("passkeys, device keys and request signatures are real.\n\n") + .append("`./mvnw -pl backend -am -Dcodename1.platform=backend test` runs the server's tests.\n\n") + .append("`backend/server.sh run --web` also builds the app for the browser and serves it at ") + .append("`http://localhost:8080/`.\n\n") + .append("Two chapters of the developer guide, at https://www.codenameone.com/developer-guide/, ") + .append("cover the sample this project started as: \"A bank that shows its locks\" walks through ") + .append("it as a customer and an admin see it, and \"Building on the sample\" explains how it is ") + .append("built and what to change.\n\n"); + } + private void appendIdeSection(StringBuilder out) { if (ide == IDE.INTELLIJ) { out.append("## IntelliJ Users\n\n") @@ -2862,6 +2927,8 @@ private static boolean isTextFile(String path) { || path.endsWith(".launch") || path.endsWith(".css") || path.endsWith(".xsd") + // A migration's comments can name the app's settings (Bastion's do). + || path.endsWith(".sql") || path.endsWith(".md") || path.endsWith(".txt") || path.endsWith(".adoc") diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java index 790837a682a..65e5e5850d5 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java @@ -79,7 +79,16 @@ public enum Template { "com.codenameone.examples.parley", "Parley", "/parley-preview.jpg", "The messaging template is an app, a server and the contract module they share; " + "an Initializr Gradle project is a single project.", - new String[0], "/parley-modules.zip", "/parley-settings.properties"); + new String[0], "/parley-modules.zip", "/parley-settings.properties"), + // A third of the same kind: a bank with a customer and an admin mode, and its + // server -- a ledger, transfers, cards, an exchange and the security around + // them. It is the project in scripts/bastion, and its resources come from + // `scripts/sync-initializr-template.py bastion`. + BASTION(false, "/bastion-src.zip", "/bastion-css.zip", "/bastion-pom.xml", + "com.codenameone.examples.bastion", "Bastion", "/bastion-preview.jpg", + "The banking template is an app, a server and the contract module they share; " + + "an Initializr Gradle project is a single project.", + new String[0], "/bastion-modules.zip", "/bastion-settings.properties"); /// The resource holding the picture shown for a template that has no live /// preview, or null for one that has. diff --git a/scripts/initializr/common/src/main/resources/bastion-css.zip b/scripts/initializr/common/src/main/resources/bastion-css.zip new file mode 100644 index 00000000000..382919cd78e Binary files /dev/null and b/scripts/initializr/common/src/main/resources/bastion-css.zip differ diff --git a/scripts/initializr/common/src/main/resources/bastion-modules.zip b/scripts/initializr/common/src/main/resources/bastion-modules.zip new file mode 100644 index 00000000000..945a1fe03d4 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/bastion-modules.zip differ diff --git a/scripts/initializr/common/src/main/resources/bastion-pom.xml b/scripts/initializr/common/src/main/resources/bastion-pom.xml new file mode 100644 index 00000000000..5e088517a28 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/bastion-pom.xml @@ -0,0 +1,406 @@ + + + 4.0.0 + + com.example.myapp + myappname + 1.0-SNAPSHOT + + com.example.myapp + myappname-common + 1.0-SNAPSHOT + jar + + + + + + ${project.groupId} + myappname-shared + ${project.version} + + + com.codenameone + codenameone-core + + + com.codenameone + codenameone-javase + test + + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + validate + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + + kotlin + + + + ${basedir}/src/main/kotlin + + + + 1.6.0 + true + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + + org.jetbrains + annotations + 13.0 + + + com.codenameone + java-runtime + provided + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + -no-reflect + -no-jdk + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + -no-reflect + -no-jdk + + + + + + + + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + simulator + + javase + + + + + + ios-debug + + + iphone + + + ios + + + + + ios-release + + + iphone + true + + + ios + true + + + + + javascript + + javascript + javascript + + + + + android + + android + android + + + + + windows + + desktop_windows + javase + + + + + mac + + desktop_macosx + javase + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + + com.codenameone + codenameone-maven-plugin + + + xalan + serializer + 2.7.3 + + + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + bytecode-compliance + css + + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + + + + + diff --git a/scripts/initializr/common/src/main/resources/bastion-preview.jpg b/scripts/initializr/common/src/main/resources/bastion-preview.jpg new file mode 100644 index 00000000000..5573bc9917f Binary files /dev/null and b/scripts/initializr/common/src/main/resources/bastion-preview.jpg differ diff --git a/scripts/initializr/common/src/main/resources/bastion-settings.properties b/scripts/initializr/common/src/main/resources/bastion-settings.properties new file mode 100644 index 00000000000..9c7728095b9 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/bastion-settings.properties @@ -0,0 +1,15 @@ +# What iOS shows before Face ID unlocks the app and the keys it keeps. +codename1.arg.ios.NSFaceIDUsageDescription=Authenticate to unlock Bastion and the keys that sign your payments +# Renames classes, encrypts strings and flattens control flow: +# off, standard, aggressive or paranoid. +codename1.arg.cn1.hardenLevel=aggressive +# Exit on launch on a jailbroken iPhone. A bank refuses to run there at all; +# Android's rooted devices are reported to the server instead (DeviceIntegrity +# and the device registration's "rooted" flag), which refuses them money. +codename1.arg.ios.detectJailbreak=true +# The app switcher shows a blank card, not the last balance on screen. +codename1.arg.ios.blockScreenshotsOnEnterBackground=true +# Play Integrity tokens for the device registration. The server verifies them +# (money/IntegrityVerifier); until it has Google credentials it runs the +# simulated verifier, which trusts the flags the app reports. +codename1.arg.android.playIntegrity=true diff --git a/scripts/initializr/common/src/main/resources/bastion-src.zip b/scripts/initializr/common/src/main/resources/bastion-src.zip new file mode 100644 index 00000000000..b7d074e80f8 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/bastion-src.zip differ diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index e0157362339..2c82a95b10e 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -31,7 +31,7 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform - `references/html-css-cheatsheet.md` — Converting common HTML/CSS snippets to CN1 components + CSS. - `references/android-to-cn1.md` — Porting Android (XML + Kotlin/Java) screens to Codename One. - `references/react-to-cn1.md` — Bringing a **React / JSX + CSS design** (e.g. a Claude-generated bundle: `*.jsx` + `tokens.css` + `styles.css`) into Codename One: the component/hook/CSS mapping, the `tokens.css` -> `theme.css` token workflow, dark-mode re-skinning, and the **render-on-desktop-then-inspect** loop. Read this when the user hands you a React/HTML design to rebuild in CN1. -- `references/demos.md` — The four full-stack demo applications (Wayline ride hailing, Savor food delivery, Parley encrypted messenger, Skirmish 3D card game): where their source is, which one to read for which feature, and the structural patterns they all share. **Read this before inventing an app structure, networking layer, live-update channel or theme layout** -- copy how a demo does it. +- `references/demos.md` — The five full-stack demo applications (Wayline ride hailing, Savor food delivery, Parley encrypted messenger, Bastion secure bank and exchange, Skirmish 3D card game): where their source is, which one to read for which feature, and the structural patterns they all share. **Read this before inventing an app structure, networking layer, live-update channel or theme layout** -- copy how a demo does it. - `references/compatibility.md` — The build-time compatibility tools: run an existing **Android** app (`src/main/android`), **Swing** or **JavaFX** app (`src/main/desktop`), **Flutter** code (`src/main/flutter`) or **Unity 2D** project (`src/main/unity`) on every Codename One target without rewriting it, the `import-*-project` goals, how to mix ported and new code, limits, and sample inputs. Read this first whenever the user has an existing app in one of those frameworks. - `references/flutter-to-cn1.md` — Bringing **Dart / Flutter widget source** into Codename One. Unlike the other porting guides this is mostly not a rewrite: the `transcode-flutter` Maven goal compiles Dart widgets to Java at build time (no Dart SDK involved), and `FlutterUI.wrap` / `FlutterUI.runApp` mount the result. Read this when the user has Dart or Flutter code, asks to embed a Flutter screen in a CN1 app, or asks whether a Flutter app can be ported. - `references/testing-and-screenshots.md` — `AbstractTest`, `TestUtils`, `screenshotTest`, the `cn1:test` Maven goal, the screenshot tolerance algorithm. @@ -381,7 +381,7 @@ If you cannot run the simulator (e.g. headless environment), **say so explicitly | "I have a React/JSX design (jsx + tokens.css), rebuild it in CN1" / "match this Claude design" | `references/react-to-cn1.md` | | "I have Android XML/Kotlin/Java, convert it" | `references/android-to-cn1.md` | | "Run my existing Android / Swing / JavaFX / Flutter / Unity app" / `import-*-project` / `src/main/android`, `desktop`, `flutter`, `unity` | `references/compatibility.md` | -| "How should I structure this app" / "show me a real example" / best practice for networking, live updates, maps, chat, payments, games | `references/demos.md` (then read the demo's source) | +| "How should I structure this app" / "show me a real example" / best practice for networking, live updates, maps, chat, payments, security, banking, games | `references/demos.md` (then read the demo's source) | | "Generate a client for this OpenAPI spec / `.proto` / GraphQL schema" / `@RestClient`, `@GrpcClient`, `@GraphQLClient` | `references/api-clients.md` | | "Write a test for this screen" / "Compare to a baseline" | `references/testing-and-screenshots.md` | | "Match this mockup" / "Compare to a Figma/Sketch/XD design" / "Import this HTML/CSS (Claude) design's tokens" / "How close is this screen to the design" | `references/mockup-comparison.md` | diff --git a/scripts/initializr/common/src/main/resources/skill/references/backend.md b/scripts/initializr/common/src/main/resources/skill/references/backend.md index 67b5d8377b4..e21f3374b80 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/backend.md +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -129,6 +129,36 @@ shared by app and backend round-trips: - Build errors: generic `Page` fields (use a concrete subclass), a body class without a no-arg constructor, interfaces, arrays other than `byte[]`. +## Errors, validation and interceptors + +- A method that throws answers a 500 that reveals nothing, a + `ResponseStatusException` its own status. For anything else declare an + `@ExceptionHandler` (`com.codename1.backend.annotations`) on the controller, or + on a `@ControllerAdvice` / `@RestControllerAdvice` bean for every controller + (`basePackages` narrows it). A handler takes the exception, the + `HttpServer.Request` and the `HandlerMethod` in any order, and returns an + `HttpServer.Response`, a JSON-able value with its `@ResponseStatus`, or a null + `Response` to decline. Resolution is decided **at build time**: the controller's + own handlers by closest exception type, then each advice in `@Order` order. +- `Error`s and security failures (`AccessDeniedException`, + `AuthenticationException`, CSRF) never reach a handler, so a catch-all can't + turn a 403 into a 200. +- Validation: `@Valid` on a `@RequestBody`/`@ModelAttribute` parameter, or a + constraint straight on a `@RequestParam`/`@PathVariable`/`@RequestHeader`. + Constraints in `com.codename1.backend.validation`: `@NotNull`, `@NotBlank`, + `@NotEmpty`, `@Size`, `@Min`, `@Max`. They compile to plain checks (a constraint + on a type that can't carry it fails the build) and throw `ValidationException`, + which answers 400 with stable JSON field paths unless a handler takes it. There + is no `@Pattern` (no regex in the class library): check in the method and throw + a `ValidationException` yourself. +- `HandlerInterceptor` beans (`com.codename1.backend.web`) run `preHandle`, + `postHandle`, `afterCompletion` with Spring's semantics, *inside* the security + chain: for auditing and metrics, never for access rules (those belong in the + `SecurityFilterChain`). +- An app whose client shows translated errors may prefer its own stable refusal + codes over `@Valid`'s field paths; the Bastion demo keeps + `ResponseStatusException`s with a code for that reason. + ## Transactions ```java @@ -160,6 +190,13 @@ public class Transfers { - `Transactions.setRollbackOnly()` in the method that began the transaction rolls it back without throwing; in a joined method it makes the outer commit throw `UnexpectedRollback`. - The injected `Session` only works inside a `@Transactional` method. - Use `?` placeholders; the same SQL runs on SQLite, PostgreSQL and MySQL. +- Entity timestamps: use `java.time.Instant` (`LocalDate`, `LocalDateTime` + also map). Unannotated they are the same epoch-millisecond `BIGINT` a `long` + held, so switching a field needs no migration; `@Temporal` opts into native + `TIMESTAMP`/`DATE`. **Pass an `Instant` as the query parameter** for such a + field -- a `long` there fails at run time ("Converter input requires + java.time.Instant"), not at compile time. For a `NOT NULL` "unset" value use + `Instant.EPOCH`; keep DTOs on the wire as epoch milliseconds. ## Background work: `@Async`, `@Scheduled`, threads @@ -193,6 +230,38 @@ public class Reports { `@Async("reports")`. One-off work: `Tasks.platform(runnable)`, `Tasks.virtual(runnable)`. +## Large bodies, streaming and proxying + +- Binary request bodies past `CN1_HTTP_SPOOL_THRESHOLD_KB` spool to a temporary + file (limit `CN1_HTTP_MAX_SPOOLED_BODY_MB`); read them with + `getBodyStream()`/`getBodyLength()`, multipart parts with + `HttpServer.Part.getInputStream()`. JSON, forms and XML keep the 8 MB limit. + The body is valid only until the request ends. +- Stream a response with `HttpServer.Response.stream(...)`; a slow client blocks + the writer instead of filling memory. A writer holding a resource should also be + `Closeable` (it is closed instead of run for `HEAD` or a vanished client). +- Outbound: `Web.open`/`WebClient.open` give a `Web.Exchange` that streams both + ways (write and close the body, then read; always `close()` in `finally`; no + redirects). `HttpProxy` forwards a request to another service and streams the + answer back. + +## Certificates, keys and key agreement + +- `com.codename1.backend.security.pki`: `Pem.decode`, `Certificate.fromPem`/ + `decode`, `CertificateValidator(anchors).at(millis).forHost(h).forPurpose(eku) + .validate(chain)` (signatures, validity, constraints, key usage, path length, + critical extensions), `CertificationRequest` (PKCS#10), `Pkcs12` read/write. + Trust anchors are always explicit: there is no system trust store. +- `Crypto.generateEcKey(Crypto.P256 | Crypto.P384)` (PKCS#8 DER) and + `Crypto.ecdh(privateKey, peerPublicKey)` -- the result is key material; run it + through HKDF before use. AES-GCM for sealing; the CBC calls are raw blocks for + protocols that need them. +- Shared with the client (`com.codename1.security`, usable in the app and the + server alike): `Hash` (SHA-2, SHA3-256, `Hash.keccak256`), `Hmac`, and + `Secp256k1` (keys, deterministic low-S ECDSA with recovery id, public-key + recovery, tweaks) -- enough for Ethereum and Bitcoin signing. See the Bastion + demo's `chain/` package for a full Ethereum client built on them. + ## Sessions ```java diff --git a/scripts/initializr/common/src/main/resources/skill/references/demos.md b/scripts/initializr/common/src/main/resources/skill/references/demos.md index 5415eadb4d1..ec3c92cf2e9 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/demos.md +++ b/scripts/initializr/common/src/main/resources/skill/references/demos.md @@ -1,6 +1,6 @@ # The demo applications: where to learn idiomatic Codename One -Codename One ships four complete, production-shaped applications. Each is an app +Codename One ships five complete, production-shaped applications. Each is an app **and** its server, written by the Codename One team, tested end to end, and kept building against the current framework. When you are unsure how something is "meant" to be done -- a screen, a navigation pattern, a live channel, a REST @@ -21,7 +21,7 @@ pattern.** Copy the shape, not the domain. It explains *why* the code is laid out the way it is -- read it before making a structural change modeled on a demo. -All four share this layout, which is itself the first best practice (Skirmish adds a +All five share this layout, which is itself the first best practice (Skirmish adds a `rules` module, the deterministic game simulation the app, server and tests all compile): | Module | Holds | @@ -41,13 +41,16 @@ README of each lists the demo accounts. | `scripts/wayline` | Ride hailing: rider, driver, admin | Role-based start screens, maps and live markers (`map/`), bottom sheets, wallet and card entry (`pay/`), admin dashboards and charts (`admin/`, `ui/Chart.java`), the reference implementation of the shared-contract full-stack pattern | | `scripts/savor` | Food delivery: customer, merchant, courier, admin | Catalog/menu browsing, cart and checkout (`customer/`), merchant order queues and menu editing (`merchant/`), two state machines (order + delivery) mirrored between `shared` enums and the server | | `scripts/parley` | End-to-end encrypted messenger with calls | Chat UI and media (`chat/`, `ui/ChatForm.java`), crypto with core APIs (`crypto/`), WebRTC calls (`com.codename1.webrtc`, `ui/CallScreen.java`), local store (`store/`), push and WebSocket delivery | +| `scripts/bastion` | Security-hardened bank and crypto exchange: customer, staff console | Security end to end: sealed key storage, biometric/PIN app lock, certificate pinning, secure windows (`security/`), device-key request signing, passkeys, TOTP and step-up (`security/RequestSigner.java`, server `money/RequestSigning.java`, `account/`), a Security Center that reports each protection's live state (`ui/SecurityCenterForm.java`); a double-entry ledger with idempotency and conditional updates (server `ledger/`), a hash-chained audit log (`audit/`), accounting reports and PDF printing (`books/`, `ui/Printout.java`), an order-matching exchange (`exchange/`), and real Ether custody on an Ethereum node (server `chain/`). Its implementor's guide ends with a security checklist | | `scripts/skirmish` | Real-time 3D card battler | Games: `GameView`/3D rendering (`battle/`), a deterministic `rules` module shared by app, server and tests, replays, in-app purchases and rewarded ads (`engage/`), matchmaking over sockets, in-app editors (`forge/`) | ## Patterns the app demos share (copy these) Wayline, Savor and Parley follow all of these. Skirmish is a game and differs: its socket is `net/GameLink.java` rather than `live/LiveChannel.java`, and it has -no `Prefs`, `Telemetry` or l10n bundles. +no `Prefs`, `Telemetry` or l10n bundles. Bastion has no live channel or +`Telemetry` (a bank sends no analytics and pulls its data on demand), and its +`net/Guard.java` adds the pinning and host allow-list every request goes through. - **Thin entry class.** `/.java` extends `Lifecycle`; configuration in `AppConfig`, preferences in `Prefs`, opt-in analytics in `Telemetry`. diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java index a381707f7c5..ea2fad2b0d2 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java @@ -278,7 +278,7 @@ private Path generateLayoutProject(Template template, ProjectOptions options, St String version) throws Exception { ByteArrayOutputStream output = new ByteArrayOutputStream(); GeneratorModel.createForPluginVersion(IDE.INTELLIJ, template, appName, packageName, options, - template.isFullStack() ? GeneratorModel.FULL_STACK_SINCE : GeneratorModel.MAVEN_LAYOUTS_SINCE) + template.isFullStack() ? GeneratorModel.templateSince(template) : GeneratorModel.MAVEN_LAYOUTS_SINCE) .writeProjectZip(output); Path dir = Files.createTempDirectory("initializr-layout-" + appName + "-"); unzipProject(output.toByteArray(), dir); diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index 7cce0cb36f6..5f8fedd02cd 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -109,7 +109,8 @@ public boolean runTest() throws Exception { if (template.isFullStack() && type == ProjectOptions.ProjectType.APP) { // An app with its server is not offered without the server. assertRefused(GeneratorModel.createForPluginVersion(IDE.INTELLIJ, template, "NoServer", - "com.acme.noserver", mavenOptions(type, false), FULL_STACK_VERSION), + "com.acme.noserver", mavenOptions(type, false), + GeneratorModel.templateSince(template)), "an app and its server", template + " must refuse to leave its server out"); continue; } @@ -373,6 +374,7 @@ private void validateMavenRefusals() { private void assertFullStackProject(Template template, Map entries, byte[] zipData, String packageName, String mainClassName, String label) throws IOException { boolean rides = template == Template.WAYLINE; + boolean bank = template == Template.BASTION; boolean game = template == Template.SKIRMISH; String name = template.SOURCE_MAIN_CLASS; String upper = template.name(); @@ -388,12 +390,12 @@ private void assertFullStackProject(Template template, Map entri // project once got the ride-hailing accounts and chapters. String readme = getText(entries, "README.md"); boolean chat = template == Template.PARLEY; - assertContains(readme, game ? "card battler" : rides ? "ride-hailing app" : chat ? "messaging app" : "food-delivery app", + assertContains(readme, game ? "card battler" : rides ? "ride-hailing app" : chat ? "messaging app" : bank ? "working bank" : "food-delivery app", label + "README names the app"); if (!game) { - assertContains(readme, (rides ? "`rider@" : chat ? "`alice@" : "`customer@") + artifact + ".example`", + assertContains(readme, (rides ? "`rider@" : chat ? "`alice@" : bank ? "`customer2@" : "`customer@") + artifact + ".example`", label + "README lists the template's own demo accounts"); - assertFalse(readme.indexOf(rides || chat ? "courier@" : "rider@") >= 0, + assertFalse(readme.indexOf(rides || chat || bank ? "courier@" : "rider@") >= 0, label + "README carries no other template's accounts"); assertFalse(chat && readme.indexOf("rider@") >= 0, label + "README carries no ride-hailing accounts"); } @@ -414,15 +416,15 @@ private void assertFullStackProject(Template template, Map entri assertContains(getText(entries, "backend/pom.xml"), "generate-annotation-stubs", label + "the server generates its half of the contract"); - assertContains(getText(entries, "shared/src/main/java/" + packagePath + (game ? "/api/AccountApi.java" : rides ? "/api/RiderApi.java" : chat ? "/api/MessageApi.java" : "/api/OrderApi.java")), + assertContains(getText(entries, "shared/src/main/java/" + packagePath + (game ? "/api/AccountApi.java" : rides ? "/api/RiderApi.java" : chat ? "/api/MessageApi.java" : bank ? "/api/BankingApi.java" : "/api/OrderApi.java")), "package " + packageName + ".api;", label + "the contract moves to the project's package"); assertContains(getText(entries, "backend/src/main/java/" + packagePath + "/SecurityConfig.java"), "package " + packageName + ";", label + "the server moves to the project's package"); - assertNotNull(entries.get("backend/src/main/resources/db/migration/" + (game ? "V1__game.sql" : "V1__accounts.sql")), + assertNotNull(entries.get("backend/src/main/resources/db/migration/" + (game ? "V1__game.sql" : bank ? "V4__accounts.sql" : "V1__accounts.sql")), label + "the server keeps its migrations"); - assertNotNull(entries.get("backend/src/test/java/" + packagePath + (game ? "/GameSocketTest.java" : rides ? "/RideFlowTest.java" : chat ? "/MessagingTest.java" : "/OrderFlowTest.java")), + assertNotNull(entries.get("backend/src/test/java/" + packagePath + (game ? "/GameSocketTest.java" : rides ? "/RideFlowTest.java" : chat ? "/MessagingTest.java" : bank ? "/LedgerTest.java" : "/OrderFlowTest.java")), label + "the server keeps its tests"); - assertNotNull(entries.get("common/src/main/java/" + packagePath + (game ? "/ui/HomeForm.java" : rides ? "/rider/RiderForm.java" : chat ? "/ui/ChatForm.java" : "/customer/CustomerForm.java")), + assertNotNull(entries.get("common/src/main/java/" + packagePath + (game ? "/ui/HomeForm.java" : rides ? "/rider/RiderForm.java" : chat ? "/ui/ChatForm.java" : bank ? "/ui/HomeForm.java" : "/customer/CustomerForm.java")), label + "the app's screens move to the project's package"); if (!game) { assertNotNull(entries.get("common/src/main/l10n/Bundle.properties"), label + "the app keeps its bundle"); @@ -435,12 +437,19 @@ private void assertFullStackProject(Template template, Map entri assertTrue(unixMode(zipData, "backend/server.sh") == 0100755, label + "server.sh must extract executable"); String settings = getText(entries, "common/codenameone_settings.properties"); - assertContains(settings, "codename1.arg.android.xapplication_attr=", label + "the template's Android hint"); - if (!game) { - assertContains(settings, "codename1.arg.ios.NSLocationWhenInUseUsageDescription=", - label + "the template's iOS location prompt"); + if (bank) { + // The bank needs no location and no cleartext exception; its hints are + // the hardening ones. + assertContains(settings, "codename1.arg.ios.detectJailbreak=true", label + "the template's iOS hardening"); + assertContains(settings, "codename1.arg.android.playIntegrity=true", label + "the template's Android hint"); + } else { + assertContains(settings, "codename1.arg.android.xapplication_attr=", label + "the template's Android hint"); + if (!game) { + assertContains(settings, "codename1.arg.ios.NSLocationWhenInUseUsageDescription=", + label + "the template's iOS location prompt"); + } + assertContains(settings, "codename1.arg.ios.includePush=", label + "the template's iOS push hint"); } - assertContains(settings, "codename1.arg.ios.includePush=", label + "the template's iOS push hint"); assertContains(settings, "codename1.arg.java.version=17", label + "a full-stack project is Java 17"); assertContains(getText(entries, "README.md"), game ? "## The game and its server" : "## The app and its server", label + "README should explain the app and its server"); @@ -492,6 +501,10 @@ private void validateFullStackTemplate() throws Exception { ProjectOptions.BuildTool.MAVEN, ProjectOptions.ProjectType.APP_WITH_BACKEND); assertRefused(GeneratorModel.createForPluginVersion(IDE.INTELLIJ, Template.WAYLINE, "NoJava8", "com.acme.nojava8", java8, FULL_STACK_VERSION), "Java 17", "A full-stack template is Java 17"); + // Bastion's app calls framework API newer than the other templates need. + assertRefused(GeneratorModel.createForPluginVersion(IDE.INTELLIJ, Template.BASTION, "TooOld", + "com.acme.tooold", mavenOptions(ProjectOptions.ProjectType.APP_WITH_BACKEND, false), + FULL_STACK_VERSION), GeneratorModel.BASTION_SINCE, "Bastion must refuse a release before its own"); ProjectOptions forced = java8.withBuild(ProjectOptions.BuildTool.GRADLE, ProjectOptions.ProjectType.APP) .forFullStack(); assertTrue(!forced.isGradle() && forced.projectType == ProjectOptions.ProjectType.APP_WITH_BACKEND @@ -698,7 +711,7 @@ private void validateMavenLayoutCombination(Template template, ProjectOptions.Pr String mainClassName = "Mvn" + template.ordinal() + type.ordinal() + (allModules ? 1 : 0) + ide.ordinal() + "App"; String packageName = "com.acme.m" + template.ordinal() + ".t" + type.ordinal() + ".i" + ide.ordinal(); String label = template + "/" + type + "/" + (allModules ? "full" : "minimal") + "/" + ide + ": "; - String version = template.isFullStack() ? FULL_STACK_VERSION : LAYOUTS_VERSION; + String version = template.isFullStack() ? GeneratorModel.templateSince(template) : LAYOUTS_VERSION; byte[] zipData = mavenZip(version, template, ide, mainClassName, packageName, mavenOptions(type, allModules)); Map entries = readZipEntries(zipData); diff --git a/scripts/initializr/e2e-shipped.sh b/scripts/initializr/e2e-shipped.sh index 1fe8b085ef1..2be2f5f259f 100755 --- a/scripts/initializr/e2e-shipped.sh +++ b/scripts/initializr/e2e-shipped.sh @@ -12,7 +12,7 @@ # # e2e-shipped.sh [--require-latest] [--no-ui] [TEMPLATE ...] # -# TEMPLATE is BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH or PARLEY; all of them +# TEMPLATE is BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY or BASTION; all of them # by default. Every other check of the templates builds them against this # checkout's 8.0-SNAPSHOT, which no developer has: the generated pom names the # release the Initializr pins, and that is the only thing this script builds @@ -59,7 +59,7 @@ for arg in "$@"; do *) TEMPLATES+=("$arg") ;; esac done -[ ${#TEMPLATES[@]} -gt 0 ] || TEMPLATES=(BAREBONES KOTLIN WAYLINE SAVOR SKIRMISH PARLEY) +[ ${#TEMPLATES[@]} -gt 0 ] || TEMPLATES=(BAREBONES KOTLIN WAYLINE SAVOR SKIRMISH PARLEY BASTION) log() { echo "[e2e-shipped] $*" >&2; } fail() { echo "[e2e-shipped] FAIL: $*" >&2; exit 1; } @@ -68,6 +68,24 @@ GENERATOR="$ROOT/scripts/initializr/common/src/main/java/com/codename1/initializ PINNED="$(sed -n 's/.*private static final String CN1_PLUGIN_VERSION = "\([^"]*\)";.*/\1/p' "$GENERATOR")" [ -n "$PINNED" ] || fail "could not read CN1_PLUGIN_VERSION from $GENERATOR" log "the Initializr pins Codename One $PINNED" +since() { + sed -n "s/.*static final String $1 = \"\([^\"]*\)\";.*/\1/p" "$GENERATOR" +} +FULL_STACK_SINCE="$(since FULL_STACK_SINCE)" +BASTION_SINCE="$(since BASTION_SINCE)" +[ -n "$FULL_STACK_SINCE" ] && [ -n "$BASTION_SINCE" ] || fail "could not read the template floors from $GENERATOR" +# The oldest release a template's generated code compiles against: Bastion +# calls framework API that first shipped in BASTION_SINCE. +template_since() { + case "$1" in + BASTION) echo "$BASTION_SINCE" ;; + *) echo "$FULL_STACK_SINCE" ;; + esac +} +# True when release $1 is $2 or newer (numeric, dot by dot). +at_least() { + [ "$(printf '%s\n%s\n' "$2" "$1" | sort -t. -k1,1n -k2,2n -k3,3n | head -n 1)" = "$2" ] +} # Published, and whole: the release workflow writes this marker last. cb="$(date +%s)" @@ -111,13 +129,18 @@ trap stop_server EXIT check_values() { local dir="$1" name="$2" - python3 - "$dir" "$PINNED" "$name" <<'PY' + python3 - "$dir" "$PINNED" "$name" "$(template_since "$name")" <<'PY' import pathlib, re, sys -root, pinned, name = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3] +root, pinned, name, floor = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4] +def release(v): + assert re.fullmatch(r'[0-9]+(\.[0-9]+)*', v), '%s: %s is not a release version' % (name, v) + return tuple(int(p) for p in v.split('.')) pom = (root / 'pom.xml').read_text(encoding='utf-8') for tag in ('cn1.version', 'cn1.plugin.version'): found = re.findall(r'<%s>([^<]*)' % (re.escape(tag), re.escape(tag)), pom) assert found == [pinned], '%s: pom.xml %s is %s, the Initializr pins %s' % (name, tag, found, pinned) + # A release, never a SNAPSHOT, and one that has the API the template calls. + assert release(found[0]) >= release(floor), '%s: pom.xml %s is %s, older than %s, the first release with the API it uses' % (name, tag, found[0], floor) assert 'https://repo.codenameone.com/maven2' in pom, name + ': pom.xml does not name the Codename One repository' # A SNAPSHOT of Codename One anywhere a build reads it. The project's own # 1.0-SNAPSHOT is fine; the agent skill's notes on building from source are docs. @@ -134,6 +157,17 @@ PY for template in "${TEMPLATES[@]}"; do zip="$WORK/zips/SHIPPED-$template.zip" + floor="$(template_since "$template")" + if [ -f "$WORK/zips/NOT-OFFERED-$template" ]; then + # Withheld is right only while the pinned release predates the template's + # floor; withheld at or past it means the gate is wrong. + if at_least "$PINNED" "$floor"; then + fail "$template: not offered at $PINNED, although it needs only $floor" + fi + log "$template: skipped, the Initializr does not offer it at $PINNED (it needs $floor)" + continue + fi + at_least "$PINNED" "$floor" || fail "$template: offered at $PINNED, older than $floor, the first release with the API it uses" [ -f "$zip" ] || fail "no download for $template" dir="$WORK/$template" mkdir -p "$dir" diff --git a/scripts/sync-initializr-template.py b/scripts/sync-initializr-template.py index 9fc091039c2..ae44e709a2e 100644 --- a/scripts/sync-initializr-template.py +++ b/scripts/sync-initializr-template.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Keep an Initializr full-stack template aligned with its sample (--write to update). - python3 scripts/sync-initializr-template.py wayline|savor|skirmish|parley [--write] + python3 scripts/sync-initializr-template.py wayline|savor|skirmish|parley|bastion [--write] scripts/ is the project CI builds and tests. The Initializr offers the same project as a template, from five resources this script derives from it: @@ -48,7 +48,7 @@ root = Path(__file__).resolve().parents[1] # The samples the Initializr offers as full-stack templates. Each is a project # under scripts/ whose Java package is com.codenameone.examples.. -SAMPLES = ('wayline', 'savor', 'skirmish', 'parley') +SAMPLES = ('wayline', 'savor', 'skirmish', 'parley', 'bastion') args = [a for a in sys.argv[1:] if not a.startswith('--')] if len(args) != 1 or args[0] not in SAMPLES: raise SystemExit('usage: sync-initializr-template.py ' + '|'.join(SAMPLES) + ' [--write]') @@ -59,7 +59,7 @@ # What the generator rewrites as text; the rest is copied as bytes. # Exactly the files the generator rewrites too (GeneratorModel.isTextFile): a # `myappname` written into any other would stay in the generated project. -TEXT = ('.java', '.xml', '.properties', '.css', '.sh', '.md', '.json', '.txt') +TEXT = ('.java', '.xml', '.properties', '.css', '.sh', '.md', '.json', '.txt', '.sql') HEADER = re.compile(r'\A/\*\n \* Copyright \(c\) .*?\*/\n', re.S) HASH_HEADER = re.compile(r'\A((?:#!.*\n)?)(?:#\n)?# Copyright \(c\) .*?(?=^[^#]|\Z)', re.S | re.M) diff --git a/scripts/test-javascript-text-selection.mjs b/scripts/test-javascript-text-selection.mjs index b95536c8277..374d05f0434 100644 --- a/scripts/test-javascript-text-selection.mjs +++ b/scripts/test-javascript-text-selection.mjs @@ -75,6 +75,42 @@ async function exercise(context, name, host, mobileDevice = null) { }, name); assert.ok(activated, name + ' is available'); } + // A form with text selection on: what is typed reaches the model, and a button pressed + // straight after typing reads it. Real pointer events, not accessible actions -- the + // bug was in the order the press and the release of one click were handled. + if (!mobile) { + await openReview('selectionsignin&selection=off'); + const typed = { 0: 'someone@bastion.example', 1: 'correct horse battery staple' }; + for (const index of [0, 1]) { + const field = await page.evaluate(index => { + const boxes = [...document.querySelectorAll('#cn1-accessibility-tree [role="textbox"]')] + .filter(el => el.getAttribute('aria-readonly') !== 'true') + .map(el => el.getBoundingClientRect()).sort((a, b) => a.y - b.y); + const box = boxes[index]; + return box && { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + }, index); + assert.ok(field, 'sign-in field ' + index + ' is on screen'); + await page.mouse.click(field.x, field.y); + await page.waitForFunction(type => document.activeElement?.tagName === 'INPUT' + && document.activeElement.type === type, index ? 'password' : 'email'); + await page.keyboard.type(typed[index], { delay: 10 }); + } + const submit = await page.evaluate(() => { + const el = [...document.querySelectorAll('#cn1-accessibility-tree [role="button"]')] + .find(el => el.getAttribute('aria-label') === 'Submit sign-in'); + const box = el && el.getBoundingClientRect(); + return box && { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + }); + assert.ok(submit, 'the submit button is on screen'); + await page.mouse.click(submit.x, submit.y); + const expected = 'Submitted ' + typed[0] + '|' + typed[1]; + await page.waitForFunction(() => /Submitted /.test(document.body.innerText)); + await page.waitForTimeout(500); + const shown = await page.evaluate(() => document.body.innerText.match(/Submitted [^\n]*/)[0]); + assert.equal(shown, expected, 'a field typed on a form with text selection on keeps its text'); + console.log('PASS', name, 'typing on a form with text selection on reaches the model before a button reads it'); + if (process.env.CN1_JS_REVIEW_ONLY === 'selectionsignin') return; + } for (const kind of ['editor', 'label']) { for (const mode of ['grab', 'focus', 'scroll', 'drag']) { await openReview('transparentcover&kind=' + kind + '&mode=' + mode); @@ -1063,7 +1099,7 @@ async function exercise(context, name, host, mobileDevice = null) { assert.deepEqual(errors, [], 'no browser/worker exceptions in review regressions'); } try { - if (process.env.CN1_JS_REVIEW_ONLY === 'true') { await exerciseReview(); return; } + if (process.env.CN1_JS_REVIEW_ONLY === 'true' || process.env.CN1_JS_REVIEW_ONLY === 'selectionsignin') { await exerciseReview(); return; } await page.goto(`http://${host}:${port}/`, { waitUntil: 'domcontentloaded' }); await page.bringToFront(); await page.waitForFunction(() => !document.getElementById('cn1-splash'), null, { timeout: 60000 }); diff --git a/scripts/tests/GenerateInitializr.java b/scripts/tests/GenerateInitializr.java index a9ef6a6940e..50c9f4a8d15 100644 --- a/scripts/tests/GenerateInitializr.java +++ b/scripts/tests/GenerateInitializr.java @@ -36,6 +36,11 @@ public static void main(String[] args) throws Exception { // the options the page starts with, and each template under its sample's own // name and package, so the sample's tests compile in the download unchanged. for (Template template : Template.values()) { + if (!GeneratorModel.isTemplateOffered(template)) { + // Not on the site at this pin; e2e-shipped.sh reads the marker and skips it. + Files.write(Paths.get(args[0],"NOT-OFFERED-"+template.name()), new byte[0]); + continue; + } ProjectOptions options = new ProjectOptions(ProjectOptions.ThemeMode.LIGHT, ProjectOptions.Accent.DEFAULT, true, false, ProjectOptions.PreviewLanguage.ENGLISH, ProjectOptions.JavaVersion.JAVA_17, "") .withBuild(ProjectOptions.BuildTool.MAVEN, ProjectOptions.ProjectType.APP).withPlatformModules(false); @@ -85,12 +90,12 @@ public static void main(String[] args) throws Exception { } // The full-stack templates, each in both of its layouts and in a colour scheme of // its own with an icon: an app, its server and the module they share. - for (Template template : new Template[] {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.PARLEY}) { + for (Template template : new Template[] {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.PARLEY, Template.BASTION}) { for (boolean full : new boolean[] {false, true}) { ProjectOptions options = ProjectOptions.defaults().forFullStack().withPlatformModules(full) .withScheme(0x0f766e, false).withIcon(new byte[] {(byte)0x89, 'P', 'N', 'G'}); GeneratorModel model=GeneratorModel.createForPluginVersion(ide,template,"LauncherProbe", - "com.example.probe",options,GeneratorModel.FULL_STACK_SINCE); + "com.example.probe",options,GeneratorModel.templateSince(template)); try(OutputStream out=Files.newOutputStream(Paths.get(args[0], template.name()+"-"+(full ? "FULL" : "APP_WITH_BACKEND")+"-"+ide.name()+".zip"))) { model.writeProjectZip(out); diff --git a/scripts/tests/generate-initializr-fixtures.py b/scripts/tests/generate-initializr-fixtures.py index 4366aa09467..e08470f3171 100644 --- a/scripts/tests/generate-initializr-fixtures.py +++ b/scripts/tests/generate-initializr-fixtures.py @@ -87,6 +87,17 @@ def check_maven_layout_fixture(archive, z): 'SAVOR': ('api/OrderApi.java', 'customer/CustomerForm.java'), 'SKIRMISH': ('api/AccountApi.java', 'ui/HomeForm.java'), 'PARLEY': ('api/ConversationApi.java', 'ui/CallScreen.java'), + 'BASTION': ('api/BankingApi.java', 'ui/HomeForm.java'), +} + + +# A build hint each template's settings carry and a plain project's do not. +TEMPLATE_HINT = { + 'WAYLINE': 'codename1.arg.ios.NSLocationWhenInUseUsageDescription=', + 'SAVOR': 'codename1.arg.ios.NSLocationWhenInUseUsageDescription=', + 'SKIRMISH': None, + 'PARLEY': 'codename1.arg.ios.NSLocationWhenInUseUsageDescription=', + 'BASTION': 'codename1.arg.ios.detectJailbreak=true', } @@ -126,7 +137,7 @@ def check_full_stack_fixture(archive, z, template): assert ('javase/pom.xml' in names) == full, archive.name for name in ['shared/src/main/java/com/example/probe/' + contract, 'backend/src/main/java/com/example/probe/SecurityConfig.java', - 'backend/src/main/resources/db/migration/' + ('V1__game.sql' if rules else 'V1__accounts.sql'), + 'backend/src/main/resources/db/migration/' + ('V1__game.sql' if rules else 'V4__accounts.sql' if template == 'BASTION' else 'V1__accounts.sql'), 'backend/server.sh', 'common/src/main/java/com/example/probe/LauncherProbe.java', 'common/src/main/java/com/example/probe/' + screen]: @@ -139,7 +150,7 @@ def check_full_stack_fixture(archive, z, template): and 'Initializr Theme Overrides' not in css), archive.name assert z.read('common/icon.png') == b'\x89PNG', archive.name settings = z.read('common/codenameone_settings.properties').decode() - assert rules or 'codename1.arg.ios.NSLocationWhenInUseUsageDescription=' in settings, archive.name + assert TEMPLATE_HINT[template] is None or TEMPLATE_HINT[template] in settings, archive.name assert ('## The game and its server' if rules else '## The app and its server') in z.read('README.md').decode(), \ archive.name for name in names: diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js index e2b213cb701..a10ea8e05e3 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -4358,6 +4358,129 @@ } }); + // Passkeys for com.codename1.impl.html5.HTML5WebAuthn. The options and the + // credential travel as WebAuthn JSON -- binary members as base64url -- which is + // what a relying party sends and expects, so they pass through unchanged + // around the ceremony. Answers { status, text }: 0 and the credential JSON, + // 1 for a dismissed sheet (NotAllowedError, which the browser also uses for + // a timeout and gives no way to tell apart), 2 and "Name:message". + function cn1WebAuthnFromB64(text) { + var b64 = String(text).replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4) { + b64 += '='; + } + var raw = global.atob(b64); + var out = new Uint8Array(raw.length); + for (var i = 0; i < raw.length; i++) { + out[i] = raw.charCodeAt(i); + } + return out.buffer; + } + + function cn1WebAuthnToB64(buffer) { + if (buffer == null) { + return null; + } + var bytes = new Uint8Array(buffer); + var raw = ''; + for (var i = 0; i < bytes.length; i++) { + raw += String.fromCharCode(bytes[i]); + } + return global.btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + function cn1WebAuthnIds(list) { + if (!list) { + return list; + } + return list.map(function(entry) { + var copy = {}; + for (var key in entry) { + copy[key] = entry[key]; + } + copy.id = cn1WebAuthnFromB64(entry.id); + return copy; + }); + } + + function cn1WebAuthnCredentialJson(credential) { + if (typeof credential.toJSON === 'function') { + return credential.toJSON(); + } + var r = credential.response; + var response = { clientDataJSON: cn1WebAuthnToB64(r.clientDataJSON) }; + if (r.attestationObject) { + response.attestationObject = cn1WebAuthnToB64(r.attestationObject); + if (typeof r.getTransports === 'function') { + response.transports = r.getTransports(); + } + } + if (r.authenticatorData) { + response.authenticatorData = cn1WebAuthnToB64(r.authenticatorData); + response.signature = cn1WebAuthnToB64(r.signature); + response.userHandle = cn1WebAuthnToB64(r.userHandle); + } + return { + id: credential.id, + rawId: cn1WebAuthnToB64(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment || null, + response: response, + clientExtensionResults: typeof credential.getClientExtensionResults === 'function' + ? credential.getClientExtensionResults() : {} + }; + } + + hostBridge.register('__cn1_webauthn__', function(request) { + var nav = global.navigator || (global.window && global.window.navigator); + var available = !!(nav && nav.credentials && typeof global.PublicKeyCredential === 'function'); + var op = request && request.op; + if (op === 'supported') { + return available; + } + if (!available) { + return { status: 2, text: 'NotSupportedError:This browser has no passkey support' }; + } + var publicKey; + try { + var parsed = JSON.parse(request.options); + publicKey = {}; + var source = parsed.publicKey || parsed; + for (var key in source) { + publicKey[key] = source[key]; + } + publicKey.challenge = cn1WebAuthnFromB64(source.challenge); + if (op === 'create') { + publicKey.user = { + id: cn1WebAuthnFromB64(source.user.id), + name: source.user.name, + displayName: source.user.displayName + }; + publicKey.excludeCredentials = cn1WebAuthnIds(source.excludeCredentials); + } else if (op === 'get') { + publicKey.allowCredentials = cn1WebAuthnIds(source.allowCredentials); + } else { + return { status: 2, text: 'invalid_options:Unknown ceremony ' + op }; + } + } catch (err) { + return { status: 2, text: 'invalid_options:' + (err && err.message || err) }; + } + var ceremony = op === 'create' ? nav.credentials.create({ publicKey: publicKey }) + : nav.credentials.get({ publicKey: publicKey }); + return ceremony.then(function(credential) { + if (!credential) { + return { status: 1, text: '' }; + } + return { status: 0, text: JSON.stringify(cn1WebAuthnCredentialJson(credential)) }; + }, function(error) { + var name = error && error.name || 'transport_error'; + if (name === 'NotAllowedError') { + return { status: 1, text: '' }; + } + return { status: 2, text: name + ':' + (error && error.message || '') }; + }); + }); + hostBridge.register('__cn1_copy_to_clipboard__', function(request) { var text = (request && request.text != null) ? String(request.text) : ''; var nav = global.navigator || (global.window && global.window.navigator); diff --git a/vm/backend/package-docs/com/codename1/security/package-info.java b/vm/backend/package-docs/com/codename1/security/package-info.java index 5743a855fc9..ee5003b0dc5 100644 --- a/vm/backend/package-docs/com/codename1/security/package-info.java +++ b/vm/backend/package-docs/com/codename1/security/package-info.java @@ -28,8 +28,13 @@ /// authenticator application shows. Being the same class the client runs, /// a code made on a device is the code a server expects. /// - [Base32] -- the encoding an authenticator's shared secret travels in. -/// - [Hash] / [Hmac] -- MD5, SHA-1 and the SHA-2 family, and HMAC over each, -/// written in Java. They are what [Otp] computes with. +/// - [Hash] / [Hmac] -- MD5, SHA-1, the SHA-2 family, SHA3-256 and Keccak-256 +/// (Ethereum's hash), and HMAC over each, written in Java. They are what +/// [Otp] computes with. +/// - [Secp256k1] -- ECDSA over the curve of Bitcoin and Ethereum: key +/// derivation, deterministic low-S signing, verification and public key +/// recovery. A server can sign a chain transaction, or check the signature +/// a wallet made on the device, with the class the device used. /// /// A server reaches for these to verify a second factor and for little else. /// What a request path computes -- a password hash, a token's signature, a diff --git a/vm/backend/src/com/codename1/backend/security/AuthorizationServerConfigurer.java b/vm/backend/src/com/codename1/backend/security/AuthorizationServerConfigurer.java index af5ab45a3ad..68e10cd5518 100644 --- a/vm/backend/src/com/codename1/backend/security/AuthorizationServerConfigurer.java +++ b/vm/backend/src/com/codename1/backend/security/AuthorizationServerConfigurer.java @@ -292,6 +292,14 @@ public void success(com.codename1.backend.HttpServer.Request request, if (session != null) { session.setAttribute(OAuth2AuthorizationServer.AUTH_TIME, Long.valueOf(clock.currentTimeMillis() / 1000L)); + String methods = OAuth2AuthorizationServer.authMethods(authentication, + secondFactor); + if (methods != null) { + session.setAttribute(OAuth2AuthorizationServer.AUTH_METHODS, methods); + } else { + // Not the earlier check's methods: this one replaced it. + session.removeAttribute(OAuth2AuthorizationServer.AUTH_METHODS); + } } } diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/server/authorization/OAuth2AuthorizationServer.java b/vm/backend/src/com/codename1/backend/security/oauth2/server/authorization/OAuth2AuthorizationServer.java index 978a88ba60d..de69eeb25d6 100644 --- a/vm/backend/src/com/codename1/backend/security/oauth2/server/authorization/OAuth2AuthorizationServer.java +++ b/vm/backend/src/com/codename1/backend/security/oauth2/server/authorization/OAuth2AuthorizationServer.java @@ -74,6 +74,13 @@ public final class OAuth2AuthorizationServer { private static final long REUSE_GRACE_MILLIS = 10000; /// Session attribute holding the last completed credential check, in epoch seconds. public static final String AUTH_TIME = "cn1.security.oauth2.authTime"; + /// Session attribute holding how the last completed credential check was + /// made: RFC 8176 method names separated by spaces, such as `pwd otp mfa`. + /// A grant made in the session carries them as its `amr` attribute, and the + /// ID token as its `amr` claim, so a resource server can tell a passkey + /// sign-in from a password one. A token customizer copies the attribute + /// into an access token that needs it. + public static final String AUTH_METHODS = "cn1.security.oauth2.authMethods"; private static final String DEVICE_TICKET = "cn1.security.oauth2.deviceTicket"; /// The attribute of a grant that holds the resource servers it is for. private static final String RESOURCE = "resource"; @@ -246,7 +253,7 @@ private HttpServer.Response metadata(HttpServer.Request request) { m.put("revocation_endpoint_auth_methods_supported", list("client_secret_basic", "client_secret_post", "none")); m.put("code_challenge_methods_supported", list("S256")); - m.put("claims_supported", list("sub", "iss", "aud", "exp", "iat", "auth_time", "nonce", + m.put("claims_supported", list("sub", "iss", "aud", "exp", "iat", "auth_time", "amr", "nonce", "at_hash", "preferred_username", "name", "email", "email_verified")); m.put("authorization_response_iss_parameter_supported", Boolean.TRUE); Map headers = new LinkedHashMap(); @@ -469,7 +476,14 @@ private HttpServer.Response authorizeChecked(HttpServer.Request request, if (nonce != null) { attributes.put("nonce", nonce); } - attributes.put("auth_time", Long.valueOf(authTime(request, now))); + long authTime = authTime(request); + if (authTime >= 0) { + attributes.put("auth_time", Long.valueOf(authTime)); + } + List amr = authMethods(request); + if (amr != null) { + attributes.put("amr", amr); + } attributes.put("authorities", authorities(authentication)); if (!resources.isEmpty()) { attributes.put(RESOURCE, new ArrayList(resources)); @@ -491,18 +505,61 @@ private HttpServer.Response authorizeChecked(HttpServer.Request request, return redirect(OAuth2Parameters.append(redirectUri, answer)); } - /// When this session completed a credential check, in epoch seconds. - /// Authentication supplied without a session has no stored sign-in time. - private static long authTime(HttpServer.Request request, long now) { + /// When this session completed a credential check, in epoch seconds, or + /// -1 when that is not known: authentication supplied without a session, + /// or a session that recorded no sign-in time. Unknown is answered as + /// unknown -- the grant then carries no `auth_time` -- never as "now", + /// which would make every freshness check (`max_age`, a step-up) pass + /// for a sign-in of any age. + private static long authTime(HttpServer.Request request) { HttpSession session = request.getSession(false); - if (session == null) { - return now / 1000L; - } - Object known = session.getAttribute(AUTH_TIME); + Object known = session == null ? null : session.getAttribute(AUTH_TIME); if (known instanceof Number) { return ((Number) known).longValue(); } - return now / 1000L; + return -1L; + } + + /// How this session's credential check was made, as RFC 8176 `amr` + /// values, or null when the session recorded none. + private static List authMethods(HttpServer.Request request) { + HttpSession session = request.getSession(false); + Object known = session == null ? null : session.getAttribute(AUTH_METHODS); + if (!(known instanceof String) || ((String) known).length() == 0) { + return null; + } + List out = new ArrayList(); + String text = (String) known; + int at = 0; + while (at < text.length()) { + int end = text.indexOf(' ', at); + if (end < 0) { + end = text.length(); + } + if (end > at) { + out.add(text.substring(at, end)); + } + at = end + 1; + } + return out; + } + + /// The RFC 8176 `amr` values for a completed sign-in, space separated: + /// `hwk` for a passkey (and `user` plus `mfa` when the authenticator + /// verified the user), otherwise `pwd`, with `otp` and `mfa` added when a + /// second factor was passed. A kind of sign-in it does not know -- a + /// remember-me cookie, say -- answers null and nothing is recorded. + /// + /// @param secondFactor whether the sign-in passed a second factor + public static String authMethods(Authentication authentication, boolean secondFactor) { + if (authentication instanceof com.codename1.backend.security.webauthn.WebAuthnAuthentication) { + return ((com.codename1.backend.security.webauthn.WebAuthnAuthentication) authentication) + .isUserVerified() ? "hwk user mfa" : "hwk"; + } + if (authentication instanceof com.codename1.backend.security.UsernamePasswordAuthenticationToken) { + return secondFactor ? "pwd otp mfa" : "pwd"; + } + return null; } private static List authorities(Authentication authentication) { @@ -991,6 +1048,10 @@ private Map issue(HttpServer.Request request, RegisteredClient c if (authTime instanceof Number) { id.claim("auth_time", Long.valueOf(((Number) authTime).longValue())); } + Object amr = authorization.getAttribute("amr"); + if (amr instanceof List && !((List) amr).isEmpty()) { + id.claim("amr", new ArrayList((List) amr)); + } Object nonce = authorization.getAttribute("nonce"); if (nonce instanceof String) { id.claim("nonce", nonce); @@ -1479,7 +1540,14 @@ private Verification verify(HttpServer.Request request, Authentication authentic return new Verification(Verification.INVALID); } Map attributes = new LinkedHashMap(); - attributes.put("auth_time", Long.valueOf(authTime(request, now))); + long authTime = authTime(request); + if (authTime >= 0) { + attributes.put("auth_time", Long.valueOf(authTime)); + } + List amr = authMethods(request); + if (amr != null) { + attributes.put("amr", amr); + } attributes.put("authorities", authorities(authentication)); Object resource = authorization.getAttribute(RESOURCE); if (resource != null) {