From 105992352cec01418b1391a7c1752d731cd4cc6a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:34:31 +0300 Subject: [PATCH 01/53] Bastion sample: skeleton, double-entry multi-asset ledger and hash-chained audit log Phase 1 of a security-showcase bank sample beside Savor and Wayline. The ledger holds fiat and crypto alike, enforces zero-sum entries, non-negative customer balances by conditional update, and idempotency keys; the audit log is a SHA-256 chain appended in the transaction of the change it records. Co-Authored-By: Claude Opus 5.5 --- scripts/bastion/.gitignore | 1 + scripts/bastion/LICENSE | 22 ++ .../backend/application-dev.properties | 1 + .../backend/application-test.properties | 3 + .../bastion/backend/application.properties | 23 ++ scripts/bastion/backend/pom.xml | 160 +++++++++ scripts/bastion/backend/server.sh | 112 +++++++ .../codenameone/examples/bastion/Caller.java | 69 ++++ .../com/codenameone/examples/bastion/Ids.java | 51 +++ .../examples/bastion/audit/Audit.java | 135 ++++++++ .../examples/bastion/domain/Asset.java | 87 +++++ .../examples/bastion/domain/AuditEvent.java | 130 ++++++++ .../examples/bastion/domain/AuditHead.java | 64 ++++ .../examples/bastion/domain/JournalEntry.java | 108 ++++++ .../bastion/domain/LedgerAccount.java | 119 +++++++ .../examples/bastion/domain/Posting.java | 97 ++++++ .../examples/bastion/ledger/AccountKinds.java | 52 +++ .../examples/bastion/ledger/Ledger.java | 288 ++++++++++++++++ .../examples/bastion/ledger/Leg.java | 39 +++ .../bastion/repository/AssetRepository.java | 31 ++ .../repository/AuditEventRepository.java | 36 ++ .../repository/AuditHeadRepository.java | 42 +++ .../repository/JournalEntryRepository.java | 29 ++ .../repository/LedgerAccountRepository.java | 58 ++++ .../bastion/repository/PostingRepository.java | 44 +++ .../resources/db/migration/V2__ledger.sql | 71 ++++ .../main/resources/db/migration/V3__audit.sql | 29 ++ .../db/migration/mysql/V1__collation.sql | 7 + .../db/migration/postgresql/V1__collation.sql | 4 + .../db/migration/sqlite/V1__collation.sql | 4 + .../examples/bastion/LedgerTest.java | 181 ++++++++++ .../codenameone/examples/bastion/Race.java | 61 ++++ scripts/bastion/pom.xml | 314 ++++++++++++++++++ scripts/bastion/shared/pom.xml | 49 +++ .../examples/bastion/api/AssetDto.java | 27 ++ 35 files changed, 2548 insertions(+) create mode 100644 scripts/bastion/.gitignore create mode 100644 scripts/bastion/LICENSE create mode 100644 scripts/bastion/backend/application-dev.properties create mode 100644 scripts/bastion/backend/application-test.properties create mode 100644 scripts/bastion/backend/application.properties create mode 100644 scripts/bastion/backend/pom.xml create mode 100755 scripts/bastion/backend/server.sh create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Caller.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Ids.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/audit/Audit.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Asset.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/AuditEvent.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/AuditHead.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/JournalEntry.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/LedgerAccount.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Posting.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/AccountKinds.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/Ledger.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/Leg.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AssetRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AuditEventRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AuditHeadRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/JournalEntryRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/LedgerAccountRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/PostingRepository.java create mode 100644 scripts/bastion/backend/src/main/resources/db/migration/V2__ledger.sql create mode 100644 scripts/bastion/backend/src/main/resources/db/migration/V3__audit.sql create mode 100644 scripts/bastion/backend/src/main/resources/db/migration/mysql/V1__collation.sql create mode 100644 scripts/bastion/backend/src/main/resources/db/migration/postgresql/V1__collation.sql create mode 100644 scripts/bastion/backend/src/main/resources/db/migration/sqlite/V1__collation.sql create mode 100644 scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/LedgerTest.java create mode 100644 scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/Race.java create mode 100644 scripts/bastion/pom.xml create mode 100644 scripts/bastion/shared/pom.xml create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AssetDto.java 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/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..362e9b06c2c --- /dev/null +++ b/scripts/bastion/backend/application-test.properties @@ -0,0 +1,3 @@ +cn1.datasource.url=:memory: + +bastion.signin.per-minute=1000 diff --git a/scripts/bastion/backend/application.properties b/scripts/bastion/backend/application.properties new file mode 100644 index 00000000000..f04ab5bbeae --- /dev/null +++ b/scripts/bastion/backend/application.properties @@ -0,0 +1,23 @@ +# 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 +# bastion.admin.email, bastion.admin.password the first admin +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..442609c7f81 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Caller.java @@ -0,0 +1,69 @@ +/* + * 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.security.Authentication; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.SecurityContextHolder; +import com.codename1.backend.security.SecurityExchange; + +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 new ResponseStatusException(401, "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. + 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/audit/Audit.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/audit/Audit.java new file mode 100644 index 00000000000..e0cdca3aa2a --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/audit/Audit.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.audit; + +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.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(System.currentTimeMillis()); + 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()); + 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. + @Transactional(readOnly = true) + public List verify() throws IOException { + List problems = new ArrayList(); + String prev = GENESIS; + long expected = 1; + // One pass over the whole table. A real deployment verifies from its + // last signed checkpoint instead of from the genesis row every time. + for (AuditEvent e : events.after(0L)) { + 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"); + } + prev = e.getHash(); + expected = e.getSeq() + 1; + } + AuditHead tail = head.findById(AuditHeadRepository.ONLY); + 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)); + } + return problems; + } + + 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())); + 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/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..137ee6eaa3d --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/AuditEvent.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.domain; + +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 long at; + + @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 long getAt() { + return at; + } + + public void setAt(long 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/JournalEntry.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/JournalEntry.java new file mode 100644 index 00000000000..6cbe7c37263 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/JournalEntry.java @@ -0,0 +1,108 @@ +/* + * 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; + +/// 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 long createdAt; + + 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 long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long 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..e93875ebbef --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/LedgerAccount.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 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 long createdAt; + + 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 long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} 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..b769453fe03 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Posting.java @@ -0,0 +1,97 @@ +/* + * 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; + +/// 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 long createdAt; + + 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 long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} 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..a0a74c15183 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/AccountKinds.java @@ -0,0 +1,52 @@ +/* + * 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"; + + 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/Ledger.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/Ledger.java new file mode 100644 index 00000000000..d963969d8f6 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/Ledger.java @@ -0,0 +1,288 @@ +/* + * 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.ResponseStatusException; +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; + + public Ledger(AssetRepository assets, LedgerAccountRepository accounts, + JournalEntryRepository entries, PostingRepository postings) { + 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 new ResponseStatusException(400, "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 new ResponseStatusException(400, "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 new ResponseStatusException(400, "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(System.currentTimeMillis()); + 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 new ResponseStatusException(404, "No such account"); + } + return account; + } + + @Transactional(readOnly = true) + public List history(String accountId) throws IOException { + return postings.findTop100ByAccountIdOrderByCreatedAtDescIdDesc(accountId); + } + + @Transactional(rollbackFor = Exception.class) + public void freeze(String accountId, boolean frozen) throws IOException { + if (accounts.setFrozen(accountId, frozen) != 1) { + throw new ResponseStatusException(404, "No such account"); + } + } + + /// 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 { + 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 new ResponseStatusException(400, "Send an Idempotency-Key of 16 to 80 characters"); + } + if (legs == null || legs.size() < 2) { + throw new ResponseStatusException(400, "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 new ResponseStatusException(422, + "That Idempotency-Key was already used for a different request"); + } + return earlier; + } + + Map perAsset = new HashMap(); + Map touched = new HashMap(); + for (Leg leg : legs) { + if (leg.amount() == 0) { + throw new ResponseStatusException(400, "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 new ResponseStatusException(404, "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 new ResponseStatusException(400, "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 = System.currentTimeMillis(); + 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(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) { + throw new ResponseStatusException(409, account.isFrozen() + ? "That account is frozen" : "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(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"); + } + } + for (String owner : owners) { + for (LedgerAccount account : accounts.findByOwnerOrderByCreatedAtAsc(owner)) { + Long total = postings.total(account.getId()); + long expected = total == null ? 0L : total.longValue(); + if (expected != account.getBalance()) { + problems.add("account " + account.getId() + " holds " + account.getBalance() + + " but its postings say " + expected); + } + } + } + return problems; + } + + 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/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..0c70fe59985 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/AuditEventRepository.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 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); +} 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/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..8df38982fb6 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/LedgerAccountRepository.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 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); + + /// 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); + + @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/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/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/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/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/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/pom.xml b/scripts/bastion/pom.xml new file mode 100644 index 00000000000..1790ab0ef39 --- /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 + + + + 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/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/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) { +} From cef4d812e02050470415c73379473bb6648a2cb8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:43:24 +0300 Subject: [PATCH 02/53] Bastion sample: sign-in, second factor, passkeys, lockout and audited auth Phase 2. An OAuth 2 authorization server (PKCE public client, five-minute access tokens, rotating refresh tokens) and a JWT resource server for /api with roles read from the account on every request. TOTP via http.mfa with recovery codes, passkeys via http.webAuthn, rate limits on sign-in, codes, registration and password changes, a temporary per-account lockout, explicit security headers, and every sign-in, refusal, enrolment and password change appended to the hash-chained audit log. Registration opens a USD checking account; admin-only routes verify the audit chain and the books. Co-Authored-By: Claude Opus 5.5 --- .../backend/application-test.properties | 3 + .../codenameone/examples/bastion/Roles.java | 30 ++ .../examples/bastion/SecurityConfig.java | 452 ++++++++++++++++++ .../bastion/account/AccountEndpoint.java | 74 +++ .../examples/bastion/account/Accounts.java | 236 +++++++++ .../bastion/account/DemoAccounts.java | 115 +++++ .../examples/bastion/account/Grants.java | 91 ++++ .../examples/bastion/account/Passwords.java | 63 +++ .../bastion/account/SecondFactor.java | 89 ++++ .../examples/bastion/account/SignInGuard.java | 198 ++++++++ .../examples/bastion/admin/AdminEndpoint.java | 89 ++++ .../examples/bastion/domain/LoginFailure.java | 78 +++ .../examples/bastion/domain/Profile.java | 66 +++ .../repository/LoginFailureRepository.java | 27 ++ .../bastion/repository/ProfileRepository.java | 35 ++ .../resources/db/migration/V4__accounts.sql | 17 + .../examples/bastion/AccessMatrixTest.java | 94 ++++ .../examples/bastion/AuthTest.java | 212 ++++++++ .../codenameone/examples/bastion/SignIn.java | 127 +++++ .../examples/bastion/api/AccountApi.java | 63 +++ .../examples/bastion/api/AdminApi.java | 37 ++ .../bastion/api/PasswordChangeDto.java | 27 ++ .../bastion/api/RecoveryCodesDto.java | 29 ++ .../examples/bastion/api/RegisterDto.java | 27 ++ .../examples/bastion/api/TotpCodeDto.java | 26 + .../bastion/api/TotpEnrollmentDto.java | 27 ++ .../examples/bastion/api/UserDto.java | 38 ++ .../examples/bastion/api/VerificationDto.java | 29 ++ 28 files changed, 2399 insertions(+) create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Roles.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/SecurityConfig.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/AccountEndpoint.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Accounts.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/DemoAccounts.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Grants.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Passwords.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/SecondFactor.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/SignInGuard.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/admin/AdminEndpoint.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/LoginFailure.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Profile.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/LoginFailureRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/ProfileRepository.java create mode 100644 scripts/bastion/backend/src/main/resources/db/migration/V4__accounts.sql create mode 100644 scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AccessMatrixTest.java create mode 100644 scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AuthTest.java create mode 100644 scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/SignIn.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AccountApi.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AdminApi.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PasswordChangeDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/RecoveryCodesDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/RegisterDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpCodeDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpEnrollmentDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/UserDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/VerificationDto.java diff --git a/scripts/bastion/backend/application-test.properties b/scripts/bastion/backend/application-test.properties index 362e9b06c2c..e16791aad17 100644 --- a/scripts/bastion/backend/application-test.properties +++ b/scripts/bastion/backend/application-test.properties @@ -1,3 +1,6 @@ cn1.datasource.url=:memory: bastion.signin.per-minute=1000 + +bastion.register.per-hour=1000 +bastion.webauthn.origins=http://bastion.test:8080 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..f0c32718f5f --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/Roles.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; + +/// The roles an account can hold. Every account opened through the app is a +/// customer; ADMIN is given only by configuration ([account.DemoAccounts]) and +/// never by any request, so no bug in an endpoint can hand it out. +public final class Roles { + public static final String CUSTOMER = "CUSTOMER"; + public static final String ADMIN = "ADMIN"; + + 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..0e68cd42e28 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/SecurityConfig.java @@ -0,0 +1,452 @@ +/* + * 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.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; + +/// 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"; + /// 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) throws IOException { + 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()); + } + return clients; + } + + /// 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) + // 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") + .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)) + .authorizationServer(server -> server + .settings(AuthorizationServerSettings.builder() + .defaultAudience(AUDIENCE).build())); + 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, + 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"); + } + Collection held = new ArrayList(); + held.addAll(account.getAuthorities()); + 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)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(AntPathRequestMatcher.antMatcher("POST", + "/api/account/register")).permitAll() + .requestMatchers("/api/admin/**").hasRole(Roles.ADMIN) + .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/account/AccountEndpoint.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/AccountEndpoint.java new file mode 100644 index 00000000000..0a07fedcb88 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/AccountEndpoint.java @@ -0,0 +1,74 @@ +/* + * 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.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.UserDto; + +/// [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; + + public AccountEndpoint(Accounts accounts, SecondFactor secondFactor) { + this.accounts = accounts; + this.secondFactor = secondFactor; + } + + @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() throws Exception { + return secondFactor.start(Caller.name()); + } + + @Override + public RecoveryCodesDto confirmTotp(TotpCodeDto code) throws Exception { + return secondFactor.confirm(Caller.name(), code == null ? null : code.code()); + } + + @Override + public RecoveryCodesDto newRecoveryCodes(TotpCodeDto code) throws Exception { + return secondFactor.renew(Caller.name(), code == null ? null : code.code()); + } +} 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..5441e8f9490 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Accounts.java @@ -0,0 +1,236 @@ +/* + * 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.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; + + public Accounts(JdbcUserDetailsManager users, PasswordEncoder encoder, + ProfileRepository profiles, Ledger ledger, Audit audit, Grants grants, + TotpService totp, RecoveryCodeService recoveryCodes) { + 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 new ResponseStatusException(409, "That e-mail already has an account"); + } + throw failed; + } + } + + @Transactional(rollbackFor = Exception.class) + private UserDto open(RegisterDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "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 new ResponseStatusException(409, "That e-mail already has an account"); + } + create(username, request.password(), name, false, 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. A customer, or with `admin` an + /// administrator and nothing else: staff do not bank on their admin + /// account. + @Transactional(rollbackFor = Exception.class) + void create(String username, String password, String displayName, boolean admin, + long openedAt) throws IOException { + users.createUser(User.withUsername(username).password(encoder.encode(password)) + .roles(admin ? Roles.ADMIN : Roles.CUSTOMER).build()); + Profile profile = new Profile(); + profile.setUsername(username); + profile.setDisplayName(displayName); + profile.setCreatedAt(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 new ResponseStatusException(404, "No such account"); + } + UserDetails signIn = users.loadUserByUsername(username); + boolean customer = false; + boolean admin = 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); + } + boolean second = totp.isEnabled(username); + return new UserDto(username, profile.getDisplayName(), customer, admin, second, + second ? recoveryCodes.remaining(username) : 0, profile.getCreatedAt()); + } + + public UserDto changePassword(String username, String current, String replacement) + throws IOException { + if (!replace(username, current, replacement)) { + // Recorded outside the change's transaction, which a refusal rolls + // back: someone guessing at the current password with a stolen + // token is exactly what the audit is for. + audit.append(username, "PASSWORD_CHANGE_REFUSED", username, "wrong current password"); + throw new ResponseStatusException(403, "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 new ResponseStatusException(400, "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 new ResponseStatusException(400, "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 new ResponseStatusException(400, "Tell us your name"); + } + if (text.length() > 120) { + throw new ResponseStatusException(400, "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..e8e62ddc24e --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/DemoAccounts.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.account; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Value; +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 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 money in their checking accounts, and an admin, 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"; + public static final String PASSWORD = "bastion-demo-password"; + + private final Accounts accounts; + private final Ledger ledger; + private final Config config; + private final String adminEmail; + private final String adminPassword; + + public DemoAccounts(Accounts accounts, Ledger ledger, Config config, + @Value("${bastion.admin.email:}") String adminEmail, + @Value("${bastion.admin.password:}") String adminPassword) { + 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); + admin(username, adminPassword); + } + if (!config.isDevelopmentProfile()) { + return; + } + admin(ADMIN, PASSWORD); + customer(CUSTOMER, "Riley Customer", 250000); + customer(SECOND_CUSTOMER, "Robin Customer", 120000); + } + + private void admin(String username, String password) throws IOException { + if (accounts.exists(username)) { + return; + } + try { + accounts.create(username, password, "Administrator", true, + 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) throws IOException { + if (accounts.exists(username)) { + return; + } + accounts.create(username, PASSWORD, name, false, System.currentTimeMillis()); + 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); + ledger.post("seed-" + username, "DEPOSIT", AccountKinds.BANK, "Opening deposit", + Arrays.asList(new Leg(cash.getId(), -cents), new Leg(checking.getId(), cents))); + } +} 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/Passwords.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Passwords.java new file mode 100644 index 00000000000..daf5a7e9783 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/Passwords.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.account; + +import com.codename1.backend.ResponseStatusException; + +/// 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 new ResponseStatusException(400, + "Choose a password of at least " + MINIMUM + " characters"); + } + if (password.length() > MAXIMUM) { + throw new ResponseStatusException(400, "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 new ResponseStatusException(400, + "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..b6c886f2792 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/SecondFactor.java @@ -0,0 +1,89 @@ +/* + * 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.ResponseStatusException; +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.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; + + public SecondFactor(TotpService totp, RecoveryCodeService recoveryCodes, Audit audit) { + this.totp = totp; + this.recoveryCodes = recoveryCodes; + this.audit = audit; + } + + /// 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) throws IOException { + if (totp.isEnabled(username)) { + throw new ResponseStatusException(409, + "This account already has an authenticator app"); + } + 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) throws IOException { + if (code == null || !totp.confirmEnrollment(username, code)) { + audit.append(username, "TOTP_CONFIRM_FAILED", username, ""); + throw new ResponseStatusException(400, "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 new ResponseStatusException(409, "Add an authenticator app first"); + } + if (code == null || !totp.verify(username, code)) { + audit.append(username, "RECOVERY_CODES_REFUSED", username, ""); + throw new ResponseStatusException(403, "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..1b6c56a5abc --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/SignInGuard.java @@ -0,0 +1,198 @@ +/* + * 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.HttpServer; +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.SecurityFilter; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +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; + +/// 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. +@Component +public class SignInGuard { + private static final RequestMatcher PASSWORD_SIGN_IN = + AntPathRequestMatcher.antMatcher("POST", "/login"); + + private final LoginFailureRepository failures; + private final JdbcUserDetailsManager users; + private final Audit audit; + private final int attempts; + private final long windowMillis; + private final long lockMillis; + + public SignInGuard(LoginFailureRepository failures, JdbcUserDetailsManager users, + Audit audit, + @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.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"); + } + } + return chain.doFilter(request); + }; + } + + /// 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) { + // Two first failures at once both insert the row, and one + // insert loses on the key. That costs the count one guess + // out of five; failing the answer would cost more. + System.err.println("bastion: lockout count lost a race: " + 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}"); + }; + } + + @Transactional(readOnly = true) + public boolean locked(String username) throws IOException { + LoginFailure row = failures.findById(username); + return row != null && row.getLockedUntil() > System.currentTimeMillis(); + } + + /// One more wrong password for `username`; locks the account on the last + /// one the window allows. + @Transactional(rollbackFor = Exception.class) + public void failed(String username) throws IOException { + long now = System.currentTimeMillis(); + LoginFailure row = failures.findById(username); + boolean fresh = row == null; + if (fresh) { + row = new LoginFailure(); + row.setUsername(username); + } + if (fresh || now - row.getFirstAt() > windowMillis) { + row.setFailures(0); + row.setFirstAt(now); + } + row.setFailures(row.getFailures() + 1); + if (row.getFailures() >= attempts) { + row.setLockedUntil(now + lockMillis); + row.setFailures(0); + row.setFirstAt(now); + audit.append(username, "ACCOUNT_LOCKED", username, + attempts + " wrong passwords; " + lockMillis / 1000 + "s"); + } + if (fresh) { + failures.insert(row); + } else { + failures.save(row); + } + } + + /// 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..9dcfc7e1f09 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/admin/AdminEndpoint.java @@ -0,0 +1,89 @@ +/* + * 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.codename1.backend.DataSource; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +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.VerificationDto; +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; + + public AdminEndpoint(Audit audit, Ledger ledger, DataSource db) { + 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); + } + + private static String admin() { + String name = Caller.name(); + if (!Caller.hasRole(Roles.ADMIN)) { + throw new ResponseStatusException(403, "Administrators only"); + } + return name; + } +} 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..caea4933615 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/LoginFailure.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 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 long firstAt; + + @Column(nullable = false) + private long lockedUntil; + + 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 long getFirstAt() { + return firstAt; + } + + public void setFirstAt(long firstAt) { + this.firstAt = firstAt; + } + + public long getLockedUntil() { + return lockedUntil; + } + + public void setLockedUntil(long lockedUntil) { + this.lockedUntil = lockedUntil; + } +} 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..10c8c11fc33 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Profile.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.domain; + +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 long createdAt; + + 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 long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} 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..c8a5d8b3319 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/LoginFailureRepository.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.repository; + +import com.codename1.orm.repository.CrudRepository; +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 { +} 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/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/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/AuthTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AuthTest.java new file mode 100644 index 00000000000..dc03ffd8ab3 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/AuthTest.java @@ -0,0 +1,212 @@ +/* + * 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.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; + + 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\"}"; + for (int iter = 0; iter < 10; iter++) { + assertEquals(403, call("POST", "/api/account/password", token, 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); + MockResponse started = call("POST", "/api/account/mfa/totp", token, null); + assertEquals(200, started.getStatus()); + String secret = (String) Json.parseObject(started.getContentAsString()).get("secret"); + assertEquals(400, call("POST", "/api/account/mfa/totp/confirm", token, + "{\"code\":\"000000\"}").getStatus()); + MockResponse confirmed = call("POST", "/api/account/mfa/totp/confirm", token, + "{\"code\":\"" + totp(secret, 0) + "\"}"); + 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, null).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); + } +} 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..b2576ce6f75 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/SignIn.java @@ -0,0 +1,127 @@ +/* + * 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.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); + return answer.getStatus() == 200 ? answer.getCookie("CN1SESSION") : null; + } + + /// 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/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..83f8bb984e5 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AccountApi.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.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +/// 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. + @POST("/api/account/mfa/totp") + void startTotp(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); +} 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..960f616acb3 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AdminApi.java @@ -0,0 +1,37 @@ +/* + * 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.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +/// What only an admin may ask: whether the records still hold. +@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); +} 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/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/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/TotpCodeDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpCodeDto.java new file mode 100644 index 00000000000..2435aaa90fa --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TotpCodeDto.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 six-digit code from the user's authenticator app. +@Mapped +public record TotpCodeDto(String code) { +} 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/UserDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/UserDto.java new file mode 100644 index 00000000000..75310ae740f --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/UserDto.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; + +/// 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) { +} 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) { +} From e5f9850a275013fe163290450dac24ddf28d4381 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:54:13 +0300 Subject: [PATCH 03/53] Bastion sample: accounts, transfers, payees, cards, signed step-up and device policy BankingApi over the ledger: own-account transfers, payments to payees with a cooling-off period, withdrawals, simulated deposits and card spends, per- transfer and daily limits, idempotency keys with a retry-as-lookup on a concurrent duplicate, ES256 request signing by a registered device key (clock skew and nonce replay refused), and an attested/rooted device policy behind a simulated integrity verifier. Every change is audited in its own transaction. MoneyTest 12 cases. Co-Authored-By: Claude Opus 5.5 --- .../backend/application-test.properties | 2 + .../examples/bastion/SecurityConfig.java | 12 + .../examples/bastion/domain/Card.java | 108 ++++++ .../examples/bastion/domain/Device.java | 120 ++++++ .../examples/bastion/domain/Payee.java | 86 ++++ .../examples/bastion/domain/Transfer.java | 153 ++++++++ .../examples/bastion/domain/UsedNonce.java | 53 +++ .../bastion/money/BankingEndpoint.java | 290 ++++++++++++++ .../examples/bastion/money/Cards.java | 129 ++++++ .../examples/bastion/money/Devices.java | 174 +++++++++ .../bastion/money/IntegrityVerifier.java | 55 +++ .../examples/bastion/money/Money.java | 303 +++++++++++++++ .../examples/bastion/money/MoneyConfig.java | 33 ++ .../examples/bastion/money/Payees.java | 150 +++++++ .../bastion/money/RequestSigning.java | 242 ++++++++++++ .../money/SimulatedIntegrityVerifier.java | 36 ++ .../bastion/repository/CardRepository.java | 47 +++ .../bastion/repository/DeviceRepository.java | 38 ++ .../bastion/repository/PayeeRepository.java | 34 ++ .../repository/TransferRepository.java | 41 ++ .../repository/UsedNonceRepository.java | 35 ++ .../main/resources/db/migration/V5__money.sql | 65 ++++ .../examples/bastion/MoneyTest.java | 367 ++++++++++++++++++ .../examples/bastion/api/AccountDto.java | 26 ++ .../examples/bastion/api/BankingApi.java | 134 +++++++ .../examples/bastion/api/CardDto.java | 27 ++ .../examples/bastion/api/CardLimitDto.java | 26 ++ .../examples/bastion/api/CardSpendDto.java | 27 ++ .../examples/bastion/api/CashDto.java | 26 ++ .../examples/bastion/api/DeviceDto.java | 27 ++ .../bastion/api/DeviceRegistrationDto.java | 28 ++ .../examples/bastion/api/NewPayeeDto.java | 26 ++ .../examples/bastion/api/OpenAccountDto.java | 26 ++ .../examples/bastion/api/PayeeDto.java | 28 ++ .../examples/bastion/api/PostingDto.java | 26 ++ .../examples/bastion/api/ReceiptDto.java | 27 ++ .../examples/bastion/api/TransferDto.java | 28 ++ 37 files changed, 3055 insertions(+) create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Card.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Device.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Payee.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Transfer.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/UsedNonce.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/BankingEndpoint.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Cards.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Devices.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/IntegrityVerifier.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Money.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/MoneyConfig.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Payees.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/RequestSigning.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/SimulatedIntegrityVerifier.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/CardRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/DeviceRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/PayeeRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TransferRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/UsedNonceRepository.java create mode 100644 scripts/bastion/backend/src/main/resources/db/migration/V5__money.sql create mode 100644 scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/MoneyTest.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/AccountDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BankingApi.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CardDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CardLimitDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CardSpendDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CashDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceRegistrationDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/NewPayeeDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/OpenAccountDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PayeeDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PostingDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ReceiptDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TransferDto.java diff --git a/scripts/bastion/backend/application-test.properties b/scripts/bastion/backend/application-test.properties index e16791aad17..af57b6992b4 100644 --- a/scripts/bastion/backend/application-test.properties +++ b/scripts/bastion/backend/application-test.properties @@ -4,3 +4,5 @@ bastion.signin.per-minute=1000 bastion.register.per-hour=1000 bastion.webauthn.origins=http://bastion.test:8080 + +bastion.transfer.per-minute=1000 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 index 0e68cd42e28..530c8e08943 100644 --- 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 @@ -297,6 +297,7 @@ public SecurityFilterChain signIn(HttpSecurity http, SignInGuard guard, Audit au 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, JdbcUserDetailsManager users) throws IOException { DefaultJwtDecoder decoder = DefaultJwtDecoder.withJwkSource(signingKeys).build(); decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( @@ -344,6 +345,17 @@ public SecurityFilterChain api(HttpSecurity http, JwkSource signingKeys, .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)) .authorizeHttpRequests(auth -> auth .requestMatchers(AntPathRequestMatcher.antMatcher("POST", "/api/account/register")).permitAll() 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..4c5c818e998 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Card.java @@ -0,0 +1,108 @@ +/* + * 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 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 long createdAt; + + 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 long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long 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..c0fcd1f9bb2 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Device.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 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 long createdAt; + + 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 long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} 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..f8cd4bfa4df --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Payee.java @@ -0,0 +1,86 @@ +/* + * 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; + +/// 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 long createdAt; + + 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 long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} 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..c7c6735b4a2 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Transfer.java @@ -0,0 +1,153 @@ +/* + * 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; + +/// 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 long createdAt; + + 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 long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long 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..5bb7d3fcbc2 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/UsedNonce.java @@ -0,0 +1,53 @@ +/* + * 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 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 long expiresAt; + + public UsedNonce() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(long expiresAt) { + this.expiresAt = expiresAt; + } +} 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..23be555083a --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/BankingEndpoint.java @@ -0,0 +1,290 @@ +/* + * 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.Config; +import com.codename1.backend.ResponseStatusException; +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.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.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.Card; +import com.codenameone.examples.bastion.domain.Device; +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; + + public BankingEndpoint(Ledger ledger, Money money, Payees payees, Cards cards, + Devices devices, RequestSigning signing, Audit audit, Config config) { + 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 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 new ResponseStatusException(400, "Say which account to open"); + } + String kind = request.kind(); + if (!AccountKinds.CHECKING.equals(kind) && !AccountKinds.SAVINGS.equals(kind) + && !AccountKinds.WALLET.equals(kind)) { + throw new ResponseStatusException(400, "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 { + if (ledger.accountsOf(owner).size() >= MAX_ACCOUNTS) { + throw new ResponseStatusException(409, "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(); + for (Posting p : ledger.history(account.getId())) { + out.add(new PostingDto(p.getId(), p.getEntryId(), p.getAsset(), p.getAmount(), + p.getCreatedAt())); + } + 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 new ResponseStatusException(400, + "Name the account to pay from, and either an account or a payee to pay"); + } + LedgerAccount from = ledger.owned(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(transfer.amount(), leaves) || RequestSigning.present()) { + device = signing.require(owner); + } + if (!leaves) { + LedgerAccount to = ledger.owned(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) { + if (to == null && AccountKinds.CHECKING.equals(a.getKind()) + && a.getAsset().equals(from.getAsset())) { + to = a; + } + } + if (to == null) { + throw new ResponseStatusException(409, "That payee cannot receive " + 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 new ResponseStatusException(404, "Not found"); + } + LedgerAccount to = ledger.owned(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.owned(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, NewPayeeDto payee) throws Exception { + return payees.rename(Devices.customer(), id, payee); + } + + @Override + public PayeeDto removePayee(String id) throws Exception { + return payees.remove(Devices.customer(), id); + } + + @Override + public List cards() throws Exception { + return cards.list(Devices.customer()); + } + + @Override + public CardDto issueCard(String accountId) throws Exception { + String owner = Devices.customer(); + return cards.issue(owner, ledger.owned(owner, accountId)); + } + + @Override + public CardDto freezeCard(String id) throws Exception { + return cards.freeze(Devices.customer(), id, true); + } + + @Override + public CardDto unfreezeCard(String id) throws Exception { + return cards.freeze(Devices.customer(), id, false); + } + + @Override + public CardDto cardLimit(String id, CardLimitDto limit) throws Exception { + if (limit == null) { + throw new ResponseStatusException(400, "Send the limit"); + } + return cards.limit(Devices.customer(), id, limit.dailyLimit()); + } + + @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 new ResponseStatusException(404, "Not found"); + } + Card card = cards.owned(owner, id); + if (card.isFrozen()) { + audit.append(owner, "CARD_DECLINED", id, "frozen"); + throw new ResponseStatusException(409, "That card is frozen"); + } + LedgerAccount from = ledger.owned(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) throws Exception { + return devices.revoke(Devices.customer(), id); + } + + private static AccountDto dto(LedgerAccount a) { + return new AccountDto(a.getId(), a.getAsset(), a.getKind(), a.getName(), a.getBalance(), + a.isFrozen(), a.getCreatedAt()); + } +} 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..d4a970cf266 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Cards.java @@ -0,0 +1,129 @@ +/* + * 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.Crypto; +import com.codename1.backend.ResponseStatusException; +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 new ResponseStatusException(400, "Cards are issued on checking accounts"); + } + if (cards.findFirstByAccountId(account.getId()) != null) { + throw new ResponseStatusException(409, "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(System.currentTimeMillis()); + 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 new ResponseStatusException(404, "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); + } + + @Transactional(rollbackFor = Exception.class) + public CardDto limit(String owner, String id, long limit) throws IOException { + if (limit < 0 || limit > MAX_LIMIT) { + throw new ResponseStatusException(400, "A card's limit is 0 to " + MAX_LIMIT); + } + Card card = owned(owner, id); + 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()); + } +} 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..1d8459e6f5c --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Devices.java @@ -0,0 +1,174 @@ +/* + * 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.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.crypto.Der; +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 needs only the access token, which is the weak point a real +/// bank closes by binding registration to a fresh sign-in with the second +/// factor, or to a code sent to the phone number on file. It 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 IntegrityVerifier integrity; + private final Audit audit; + + public Devices(DeviceRepository devices, IntegrityVerifier integrity, Audit audit) { + this.devices = devices; + this.integrity = integrity; + this.audit = audit; + } + + @Transactional(rollbackFor = Exception.class) + public DeviceDto register(String owner, DeviceRegistrationDto request) throws IOException { + if (request == null || request.publicKey() == null) { + throw new ResponseStatusException(400, "Send the device's public key"); + } + 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 new ResponseStatusException(400, "The key must be an EC P-256 public key"); + } + } catch (IOException unreadable) { + throw new ResponseStatusException(400, "The key must be an EC P-256 public key"); + } + List held = devices.findByOwnerOrderByCreatedAtAsc(owner); + int live = 0; + for (Device d : held) { + live += d.isRevoked() ? 0 : 1; + } + if (live >= MAX_DEVICES) { + throw new ResponseStatusException(409, "Remove a device before adding another"); + } + String platform = clip(request.platform(), 20); + IntegrityVerifier.Verdict verdict = integrity.verify(platform, request.integrityToken(), + 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(System.currentTimeMillis()); + devices.insert(device); + audit.append(owner, "DEVICE_REGISTERED", device.getId(), platform + + (trusted(device) ? " trusted" : " untrusted") + + (device.isRooted() ? " rooted" : "") + (device.isAttested() ? " attested" : "")); + 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; + } + + @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 new ResponseStatusException(404, "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()); + } + + 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 new ResponseStatusException(403, "Only customers hold accounts"); + } + return name; + } +} 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..d48faeec5e3 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/IntegrityVerifier.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.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; + + /// 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..88c0e1eddbf --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Money.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.money; + +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.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.Transfer; +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.JournalEntryRepository; +import com.codenameone.examples.bastion.repository.ProfileRepository; +import com.codenameone.examples.bastion.repository.TransferRepository; + +import java.io.IOException; +import java.util.Arrays; + +/// 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** (fiat only; see [#checkLimits]): per movement, per 24 +/// hours, and per new payee during its cooling-off period. +/// +/// 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 long perTransfer; + private final long daily; + private final long stepUp; + private final long coolOffMillis; + private final long coolOffLimit; + private final boolean enforceDevices; + + public Money(Ledger ledger, Audit audit, TransferRepository transfers, + JournalEntryRepository entries, ProfileRepository profiles, + @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) { + this.ledger = ledger; + this.audit = audit; + this.transfers = transfers; + this.entries = entries; + this.profiles = profiles; + // In ledger units of a fiat asset: cents. 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()); + } + + public long coolOffMillis() { + return coolOffMillis; + } + + public boolean enforcesDevices() { + return enforceDevices; + } + + /// Whether a movement of `amount` that does or does not leave the caller + /// must be signed. + public boolean needsSignature(long amount, boolean leaves) { + return amount > stepUp || (leaves && enforceDevices); + } + + /// 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 new ResponseStatusException(403, "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 new ResponseStatusException(400, "Choose two different accounts"); + } + 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)); + } + + /// 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 new ResponseStatusException(400, "The amount must be more than zero"); + } + 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 new ResponseStatusException(422, + "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); + 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); + 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, in ledger units of the asset, applied to fiat only. A coin + /// is not a cent: a real exchange states its crypto limits in a reference + /// currency at the current price, which needs the prices phase 4 brings. + /// Until then a crypto withdrawal is held by the signature and the trusted + /// device alone. + private void checkLimits(Move m) throws IOException { + Asset asset = ledger.asset(m.from.getAsset()); + if (!"FIAT".equals(asset.getKind())) { + return; + } + long now = System.currentTimeMillis(); + if (m.amount > perTransfer) { + throw new ResponseStatusException(409, "That is more than one payment may move"); + } + Long sent = transfers.sentSince(m.owner, asset.getCode(), now - DAY); + if ((sent == null ? 0L : sent.longValue()) + m.amount > daily) { + throw new ResponseStatusException(409, "That would pass your limit for the day"); + } + if (m.payee != null && now < m.payee.getCreatedAt() + coolOffMillis) { + Long paid = transfers.throughSince(m.payee.getId(), m.payee.getCreatedAt()); + if ((paid == null ? 0L : paid.longValue()) + m.amount > coolOffLimit) { + throw new ResponseStatusException(409, "A new payee can receive only " + + coolOffLimit + " until their cooling-off period ends"); + } + } + if (m.card != null) { + Long spent = transfers.throughSince(m.card.getId(), now - DAY); + if ((spent == null ? 0L : spent.longValue()) + m.amount > m.card.getDailyLimit()) { + throw new ResponseStatusException(409, "That is over the card's daily limit"); + } + } + } + + static ReceiptDto receipt(Transfer t) { + return new ReceiptDto(t.getId(), t.getKind(), t.getFromAccount(), t.getToAccount(), + t.getAsset(), t.getAmount(), t.getMemo(), t.getCreatedAt()); + } + + /// What one call asked to move. + private static final class Move { + final String owner; + final 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; + + 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..246b63df1bf --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/MoneyConfig.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.money; + +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; + +/// The pieces of money movement a deployment replaces. +@Configuration +public class MoneyConfig { + /// The device integrity check. Simulated: see [IntegrityVerifier] for what + /// a production one calls, and why this sample cannot. + @Bean + public IntegrityVerifier integrityVerifier() { + return new SimulatedIntegrityVerifier(); + } +} 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..aa487297653 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/Payees.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.money; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +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. +@Component +public class Payees { + static final int MAX_PAYEES = 200; + + private final PayeeRepository payees; + private final JdbcUserDetailsManager users; + private final Money money; + private final Audit audit; + + public Payees(PayeeRepository payees, JdbcUserDetailsManager users, Money money, + Audit audit) { + this.payees = payees; + this.users = users; + this.money = money; + this.audit = audit; + } + + @Transactional(rollbackFor = Exception.class) + public PayeeDto add(String owner, NewPayeeDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Name the payee"); + } + String username; + try { + username = Accounts.email(request.email()); + } catch (ResponseStatusException malformed) { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + if (username.equals(owner)) { + throw new ResponseStatusException(400, "Move money between your own accounts instead"); + } + // Saying "no such customer" tells the caller who banks here. The + // request is signed by a registered device and audited, and every + // payee is a row a person reviews, which makes this a poor directory. + if (!users.userExists(username)) { + throw new ResponseStatusException(404, "No customer has that address"); + } + if (payees.findFirstByOwnerAndUsername(owner, username) != null) { + throw new ResponseStatusException(409, "That payee is already on your list"); + } + if (payees.findByOwnerOrderByCreatedAtAsc(owner).size() >= MAX_PAYEES) { + throw new ResponseStatusException(409, "Remove a payee before adding another"); + } + Payee payee = new Payee(); + payee.setId(Ids.next()); + payee.setOwner(owner); + payee.setUsername(username); + payee.setNickname(nickname(request.nickname(), username)); + payee.setCreatedAt(System.currentTimeMillis()); + 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)) { + out.add(dto(p)); + } + return out; + } + + /// `owner`'s payee `id`, or 404 whether it is missing or someone else's. + @Transactional(readOnly = true) + public Payee owned(String owner, String id) throws IOException { + Payee payee = id == null ? null : payees.findById(id); + if (payee == null || !payee.getOwner().equals(owner)) { + throw new ResponseStatusException(404, "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); + payees.deleteById(id); + 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(), + p.getCreatedAt() + 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..d5c91c10bff --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/RequestSigning.java @@ -0,0 +1,242 @@ +/* + * 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.Crypto; +import com.codename1.backend.HttpServer; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +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.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 = "Bastion-Signature"; + /// Two minutes either way. Phones' clocks are set by the network and are + /// rarely more than seconds out. + public static final long SKEW_MILLIS = 120000L; + + 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 new ResponseStatusException(403, "This needs a signature from your device: " + + 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("v1;")) { + 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 (!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(); + String signed = request.getMethod() + "|" + request.getTarget() + "|" + + Ids.hex(Crypto.sha256(body == null ? new byte[0] : body)) + "|" + nonce + "|" + + ts; + 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(now); + UsedNonce used = new UsedNonce(); + used.setId(id); + used.setExpiresAt(expiresAt); + nonces.insert(used); + } + + @Transactional(readOnly = true) + private boolean nonceKnown(String id) throws IOException { + return nonces.findById(id) != null; + } + + private static boolean nonceShaped(String nonce) { + if (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; + } + + /// The value of `name=` among the header's `;`-separated fields, or null. + 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; + } + + 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..63d7de05c76 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/SimulatedIntegrityVerifier.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.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 { + @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/CardRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/CardRepository.java new file mode 100644 index 00000000000..fca141d6a73 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/CardRepository.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.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); +} 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/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/TransferRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TransferRepository.java new file mode 100644 index 00000000000..c9bf618a9a7 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TransferRepository.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 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") long 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") long 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..efa58816f04 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/UsedNonceRepository.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.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") long now); +} 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/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..fce62967414 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/MoneyTest.java @@ -0,0 +1,367 @@ +/* + * 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.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.security.crypto.Der; +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.audit.Audit; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.Signature; +import java.security.spec.ECGenParameterSpec; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +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.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 { + @Autowired + private MockMvc mvc; + + @Autowired + private Audit audit; + + /// A customer signed in, with a registered device. + private final class Customer { + final String name; + final String token; + final String checking; + KeyPair key; + String device; + + Customer(boolean rooted) throws Exception { + name = SignIn.register(mvc, "m" + UUID.randomUUID().toString().substring(0, 12) + + "@bastion.example", "a long passphrase for money"); + token = SignIn.token(mvc, name, "a long passphrase for money"); + checking = String.valueOf(((Map) accounts().get(0)).get("id")); + key = newKey(); + device = register(key, rooted); + } + + String register(KeyPair pair, boolean rooted) throws Exception { + MockResponse answer = send(json("/api/devices", "{\"publicKey\":\"" + + Base64Url.encode(pair.getPublic().getEncoded()) + "\",\"platform\":\"and\"," + + "\"rooted\":" + rooted + ",\"attested\":true,\"integrityToken\":\"t\"}")); + 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, KeyPair pair, long ts, String nonce, + String idempotencyKey) throws Exception { + MockRequestBuilder request = json(path, body) + .header("Bastion-Signature", signature("POST", path, body, pair, ts, nonce)); + if (idempotencyKey != null) { + request = request.header("Idempotency-Key", idempotencyKey); + } + return send(request); + } + + 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, KeyPair pair, long ts, + String nonce) throws Exception { + String text = method + "|" + path + "|" + + Ids.hex(MessageDigest.getInstance("SHA-256").digest(body.getBytes("UTF-8"))) + + "|" + nonce + "|" + ts; + Signature signer = Signature.getInstance("SHA256withECDSA"); + signer.initSign(pair.getPrivate()); + signer.update(text.getBytes("UTF-8")); + byte[] jose = Der.ecdsaDerToJose(signer.sign(), 32); + 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 UUID.randomUUID().toString(); + } + + private static String nonce() { + return UUID.randomUUID().toString().replace("-", ""); + } + + private static KeyPair newKey() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + return generator.generateKeyPair(); + } + + private static String internal(String from, String to, long amount) { + return "{\"fromAccountId\":\"" + from + "\",\"toAccountId\":\"" + to + "\",\"amount\":" + + amount + ",\"memo\":\"save\"}"; + } + + @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 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 sig = a.signature("POST", "/api/transfers", a.payBody(payee, 10), + a.key, System.currentTimeMillis(), nonce()); + MockResponse swapped = a.send(json("/api/transfers", a.payBody(payee, 9000)) + .header("Bastion-Signature", sig).header("Idempotency-Key", key())); + assertEquals(403, swapped.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 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); + KeyPair 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.send(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()); + assertEquals(200, a.send(post("/api/cards/" + card.get("id") + "/unfreeze")).getStatus()); + assertEquals(200, a.send(json(spend, coffee).header("Idempotency-Key", key())).getStatus()); + assertEquals(49100, a.balance(a.checking)); + } +} 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/BankingApi.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BankingApi.java new file mode 100644 index 00000000000..91f919d9d8f --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BankingApi.java @@ -0,0 +1,134 @@ +/* + * 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 and new payees. 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))|nonce|ts`. 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 { + @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, @Body NewPayeeDto payee, + OnComplete> callback); + + @DELETE("/api/payees/{id}") + void removePayee(@Path("id") String id, OnComplete> callback); + + @GET("/api/cards") + void cards(OnComplete>> callback); + + /// Issues the virtual card of a checking account; one per account. + @POST("/api/accounts/{id}/card") + void issueCard(@Path("id") String accountId, 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, OnComplete> callback); + + @PUT("/api/cards/{id}/limit") + void cardLimit(@Path("id") String id, @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. + @DELETE("/api/devices/{id}") + void revokeDevice(@Path("id") String id, OnComplete> callback); +} 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/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..ce4abd81076 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceRegistrationDto.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; + +/// 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. +@Mapped +public record DeviceRegistrationDto(String publicKey, String platform, boolean rooted, boolean attested, String integrityToken) { +} 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/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/PostingDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PostingDto.java new file mode 100644 index 00000000000..43462bafd1f --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PostingDto.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 line of an account's history: `amount` in (positive) or out (negative). +@Mapped +public record PostingDto(String id, String entryId, String asset, long amount, long createdAt) { +} 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/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) { +} From 8c2bb23540fe5b530c9e53dbd3b444642c53e968 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:13:32 +0300 Subject: [PATCH 04/53] Bastion sample: crypto exchange on the ledger, crypto withdrawals and limits, fresh sign-in for devices Matching engine with price-time priority behind a market row lock, holds in HOLD accounts, one balanced two-asset journal entry per trade, taker fee and once-per-order rounding in the bank's favour, self-trade prevention, market orders that never leave a hold. Allow-listed withdrawal addresses with cooling-off, crypto limits valued at the index price, auth_time in access tokens and a fresh sign-in (plus password without TOTP) to register a device. Dev-profile market maker and price feed. Co-Authored-By: Claude Opus 5.5 --- .../backend/application-test.properties | 3 + .../codenameone/examples/bastion/Caller.java | 14 + .../examples/bastion/SecurityConfig.java | 18 +- .../examples/bastion/audit/Audit.java | 5 + .../bastion/domain/CryptoAddress.java | 121 ++++ .../examples/bastion/domain/Market.java | 119 ++++ .../examples/bastion/domain/Trade.java | 187 ++++++ .../examples/bastion/domain/TradeOrder.java | 238 ++++++++ .../bastion/exchange/CryptoWallets.java | 159 +++++ .../examples/bastion/exchange/Exchange.java | 553 ++++++++++++++++++ .../bastion/exchange/ExchangeEndpoint.java | 273 +++++++++ .../bastion/exchange/MarketMaker.java | 166 ++++++ .../examples/bastion/exchange/Markets.java | 175 ++++++ .../examples/bastion/ledger/AccountKinds.java | 3 + .../bastion/money/BankingEndpoint.java | 2 +- .../examples/bastion/money/Devices.java | 68 ++- .../examples/bastion/money/Money.java | 89 ++- .../repository/CryptoAddressRepository.java | 30 + .../bastion/repository/MarketRepository.java | 51 ++ .../repository/TradeOrderRepository.java | 53 ++ .../bastion/repository/TradeRepository.java | 34 ++ .../resources/db/migration/V6__exchange.sql | 74 +++ .../examples/bastion/ExchangeTest.java | 366 ++++++++++++ .../examples/bastion/MoneyTest.java | 99 +++- .../examples/bastion/api/BookDto.java | 29 + .../bastion/api/CryptoAddressDto.java | 26 + .../bastion/api/CryptoWithdrawalDto.java | 26 + .../bastion/api/DeviceRegistrationDto.java | 7 +- .../examples/bastion/api/ExchangeApi.java | 86 +++ .../examples/bastion/api/LevelDto.java | 26 + .../examples/bastion/api/MarketDto.java | 27 + .../bastion/api/NewCryptoAddressDto.java | 26 + .../examples/bastion/api/NewOrderDto.java | 28 + .../examples/bastion/api/OrderDto.java | 26 + .../examples/bastion/api/TickerDto.java | 27 + .../examples/bastion/api/TradeDto.java | 26 + 36 files changed, 3235 insertions(+), 25 deletions(-) create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/CryptoAddress.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Market.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Trade.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/TradeOrder.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/CryptoWallets.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/Exchange.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/ExchangeEndpoint.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/MarketMaker.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/Markets.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/CryptoAddressRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/MarketRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TradeOrderRepository.java create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TradeRepository.java create mode 100644 scripts/bastion/backend/src/main/resources/db/migration/V6__exchange.sql create mode 100644 scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/ExchangeTest.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/BookDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CryptoAddressDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/CryptoWithdrawalDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ExchangeApi.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/LevelDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/MarketDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/NewCryptoAddressDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/NewOrderDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/OrderDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TickerDto.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/TradeDto.java diff --git a/scripts/bastion/backend/application-test.properties b/scripts/bastion/backend/application-test.properties index af57b6992b4..6e936a9382e 100644 --- a/scripts/bastion/backend/application-test.properties +++ b/scripts/bastion/backend/application-test.properties @@ -6,3 +6,6 @@ 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:6000000,STORM-USD:BTC:USD:1000:6000000 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 index 442609c7f81..3771e434e5c 100644 --- 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 @@ -23,6 +23,7 @@ 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; @@ -58,6 +59,19 @@ public static boolean hasRole(String role) { /// 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(); + } + public static String address() { SecurityExchange exchange = SecurityExchange.current(); if (exchange == null) { 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 index 530c8e08943..c2f1a2d1c32 100644 --- 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 @@ -63,6 +63,7 @@ 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; @@ -285,9 +286,24 @@ public SecurityFilterChain signIn(HttpSecurity http, SignInGuard guard, Audit au 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())); + .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())); + } + } + })); return http.build(); } 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 index e0cdca3aa2a..d5ea8306fa9 100644 --- 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 @@ -72,6 +72,11 @@ public AuditEvent append(String actor, String action, String subject, String det 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; } 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..0158f481ec6 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/CryptoAddress.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 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 long usableAt; + + @Column(nullable = false) + private boolean removed; + + @Column(nullable = false) + private long createdAt; + + 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 long getUsableAt() { + return usableAt; + } + + public void setUsableAt(long usableAt) { + this.usableAt = usableAt; + } + + public boolean isRemoved() { + return removed; + } + + public void setRemoved(boolean removed) { + this.removed = removed; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} 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/Trade.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Trade.java new file mode 100644 index 00000000000..929584e7da3 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/Trade.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.domain; + +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 long createdAt; + + 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 long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long 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..5c9dced6e23 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/domain/TradeOrder.java @@ -0,0 +1,238 @@ +/* + * 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; + +/// 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 long createdAt; + + @Column(nullable = false) + private long updatedAt; + + 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 long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } +} 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..17c0e25d868 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/CryptoWallets.java @@ -0,0 +1,159 @@ +/* + * 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.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.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 Ledger ledger; + private final Audit audit; + private final long coolOffMillis; + + public CryptoWallets(CryptoAddressRepository addresses, Ledger ledger, Audit audit, + @Value("${bastion.address.cooloff-hours:24}") String coolOffHours) { + this.addresses = addresses; + 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 new ResponseStatusException(400, "Send the address"); + } + Asset asset = ledger.asset(request.asset()); + if ("FIAT".equals(asset.getKind())) { + throw new ResponseStatusException(400, "Only a coin is withdrawn to an address"); + } + String address = request.address().trim(); + if (!wellFormed(address)) { + throw new ResponseStatusException(400, "That is not an address"); + } + int live = 0; + for (CryptoAddress a : addresses.findByOwnerOrderByCreatedAtAsc(owner)) { + if (!a.isRemoved()) { + live++; + if (a.getAsset().equals(asset.getCode()) && a.getAddress().equals(address)) { + throw new ResponseStatusException(409, "That address is already on your list"); + } + } + } + if (live >= MAX_ADDRESSES) { + throw new ResponseStatusException(409, "Remove an address before adding another"); + } + 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(now + coolOffMillis); + a.setCreatedAt(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 new ResponseStatusException(404, "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(), a.getCreatedAt()); + } +} 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..b144264b6da --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/Exchange.java @@ -0,0 +1,553 @@ +/* + * 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.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.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; + + public Exchange(Ledger ledger, Audit audit, Markets markets, MarketRepository marketRows, + TradeOrderRepository orders, TradeRepository trades, + @Value("${bastion.exchange.taker-fee-bps:20}") String feeBps) { + 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`. A retry with the same key answers the + /// order its first attempt placed. + public OrderDto place(String owner, String key, NewOrderDto request) throws IOException { + if (key == null || key.length() < 16 || key.length() > 80) { + throw new ResponseStatusException(400, "Send an Idempotency-Key of 16 to 80 characters"); + } + if (request == null) { + throw new ResponseStatusException(400, "Send the order"); + } + try { + return placeOnce(owner, key, request); + } 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); + } + throw failed; + } + } + + @Transactional(readOnly = true) + private TradeOrder existing(String owner, String key) throws IOException { + return orders.findFirstByOwnerAndClientKey(owner, key); + } + + @Transactional(rollbackFor = Exception.class) + private OrderDto placeOnce(String owner, String key, NewOrderDto r) 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 new ResponseStatusException(422, + "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 new ResponseStatusException(400, "An order is a BUY or a SELL"); + } + boolean limit = LIMIT.equals(r.type()); + if (!limit && !MARKET.equals(r.type())) { + throw new ResponseStatusException(400, "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 new ResponseStatusException(400, "The quantity must be a whole number of " + + m.getLot() + "-unit lots"); + } + if (limit && r.price() <= 0) { + throw new ResponseStatusException(400, "A limit order needs a price"); + } + if (!limit && (r.price() != 0 || r.budget() != 0)) { + throw new ResponseStatusException(400, "A market sell names only a quantity"); + } + if (limit && r.budget() != 0) { + throw new ResponseStatusException(400, "A limit order names no budget"); + } + if (buy) { + long most = Markets.ceilDiv(exact(r.price(), r.quantity()), scale); + held = Math.addExact(most, fee(most)); + } else { + held = r.quantity(); + } + } else { + if (r.budget() <= 0 || r.price() != 0 || r.quantity() != 0) { + throw new ResponseStatusException(400, "A market buy names only a budget"); + } + held = r.budget(); + } + + 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(now); + o.setUpdatedAt(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(System.currentTimeMillis()); + 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 { + TradeOrder peek = id == null ? null : orders.findById(id); + if (peek == null || !peek.getOwner().equals(owner)) { + throw new ResponseStatusException(404, "No such order"); + } + Market m = lock(peek.getMarket()); + // Read again behind the lock: it may have filled while we waited. + TradeOrder o = orders.findById(id); + if (!OPEN.equals(o.getStatus())) { + throw new ResponseStatusException(409, "That order is already " + o.getStatus()); + } + List events = new ArrayList(); + finish(m, o, events); + o.setUpdatedAt(System.currentTimeMillis()); + 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()); + List book = buy + ? orders.asks(m.getId(), SELL, OPEN, limit ? taker.getPrice() : Long.MAX_VALUE) + : orders.bids(m.getId(), BUY, OPEN, limit ? taker.getPrice() : 0L); + 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(System.currentTimeMillis()); + 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(System.currentTimeMillis()); + 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(Math.addExact(o.getNotional(), + Math.multiplyExact(price, quantity)), scale); + return Math.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 = Math.addExact(buy.getNotional(), product); + long sellNotional = Math.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(System.currentTimeMillis()); + 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()}); + } + } + + private Market lock(String id) throws IOException { + if (id == null || marketRows.hold(id) != 1) { + throw new ResponseStatusException(404, "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 new ResponseStatusException(409, "Open a " + asset + " " + + (fiatSide ? "checking account" : "wallet") + " first"); + } + 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(Math.multiplyExact(quote, feeBps), 10000L); + } + + private static long exact(long a, long b) { + try { + return Math.multiplyExact(a, b); + } catch (ArithmeticException tooLarge) { + throw new ResponseStatusException(400, "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(Math.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()); + } +} 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..0b92a416689 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/ExchangeEndpoint.java @@ -0,0 +1,273 @@ +/* + * 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.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.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; + + public ExchangeEndpoint(Markets markets, Exchange exchange, CryptoWallets wallets, + Ledger ledger, Money money, RequestSigning signing, Audit audit, + TradeOrderRepository orders, TradeRepository trades) { + 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, NewOrderDto order) throws Exception { + return exchange.place(Devices.customer(), idempotencyKey, order); + } + + @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())); + } + 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) throws Exception { + return wallets.remove(Devices.customer(), 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.owned(owner, id); + if (!AccountKinds.WALLET.equals(from.getKind())) { + throw new ResponseStatusException(400, "Coins are withdrawn from a wallet"); + } + Device device = signing.require(owner); + money.requireTrusted(device); + if (withdrawal == null) { + throw new ResponseStatusException(400, "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 new ResponseStatusException(403, "Withdraw only to an address on your list"); + } + if (!address.getAsset().equals(from.getAsset())) { + throw new ResponseStatusException(400, "That address is for " + address.getAsset()); + } + if (System.currentTimeMillis() < address.getUsableAt()) { + audit.append(owner, "CRYPTO_OUT_REFUSED", address.getId(), "cooling off until " + + address.getUsableAt()); + throw new ResponseStatusException(409, "That address is new: it can be used from " + + 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; + } + } +} 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..61ecfc349ca --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/MarketMaker.java @@ -0,0 +1,166 @@ +/* + * 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.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.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; + +/// 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; + private final SecureRandom random = new SecureRandom(); + + public MarketMaker(Exchange exchange, Markets markets, Ledger ledger, + TradeOrderRepository orders, Config config, + @Value("${bastion.market-maker.enabled:true}") String enabled) { + this.exchange = exchange; + 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); + 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"); + } + 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..3f98423e65a --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/Markets.java @@ -0,0 +1,175 @@ +/* + * 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.ResponseStatusException; +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 [Math#multiplyExact], 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 : configured.split(",")) { + String[] f = one.trim().split(":"); + 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 new ResponseStatusException(404, "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); + } + } + + /// 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 new ResponseStatusException(409, "There is no price for " + 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(Math.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. + static long scale(Asset asset) { + long s = 1; + for (int i = 0; i < asset.getDecimals(); i++) { + s = Math.multiplyExact(s, 10L); + } + return s; + } + + /// `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 index a0a74c15183..43708555acb 100644 --- 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 @@ -37,6 +37,9 @@ public final class AccountKinds { /// 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"; private AccountKinds() { } 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 index 23be555083a..ee47c7c0813 100644 --- 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 @@ -146,7 +146,7 @@ public ReceiptDto transfer(String idempotencyKey, String signature, TransferDto 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(transfer.amount(), leaves) || RequestSigning.present()) { + if (money.needsSignature(from, transfer.amount(), leaves) || RequestSigning.present()) { device = signing.require(owner); } if (!leaves) { 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 index 1d8459e6f5c..689be2105fa 100644 --- 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 @@ -22,7 +22,12 @@ 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.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; @@ -44,10 +49,25 @@ /// 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 needs only the access token, which is the weak point a real -/// bank closes by binding registration to a fresh sign-in with the second -/// factor, or to a code sent to the phone number on file. It is audited, and -/// the customer's device list shows every key. +/// **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. **The second factor, or the password again.** An account with an +/// authenticator app cannot finish a sign-in without the code, so its +/// fresh sign-in already *was* a second-factor sign-in. An account without +/// one must type its password into the registration as well; a wrong one +/// counts toward the sign-in lockout like any other. (A passkey-only +/// account is asked too: the token does not say *how* its sign-in was +/// made, and a passkey account can still sign in by password. Carrying an +/// `amr` claim would let a passkey sign-in skip 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 @@ -57,18 +77,54 @@ public class Devices { private final DeviceRepository devices; 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) { + public Devices(DeviceRepository devices, IntegrityVerifier integrity, Audit audit, + TotpService totp, JdbcUserDetailsManager users, PasswordEncoder encoder, + SignInGuard guard, @Value("${bastion.device.fresh-minutes:10}") String freshMinutes) { this.devices = devices; 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; } - @Transactional(rollbackFor = Exception.class) + /// Registers a key once the caller has shown a fresh sign-in, and the + /// password when there is no second factor; 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 new ResponseStatusException(400, "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 new ResponseStatusException(403, + "Sign in again to add a device: the sign-in must be a few minutes old"); + } + if (!totp.isEnabled(owner)) { + String password = request.password(); + if (password == null + || !encoder.matches(password, users.loadUserByUsername(owner).getPassword())) { + guard.failed(owner); + audit.append(owner, "DEVICE_REFUSED", owner, "password"); + throw new ResponseStatusException(403, + "Enter your password to add a device, or turn on an authenticator app"); + } + } + return registerOnce(owner, request); + } + + @Transactional(rollbackFor = Exception.class) + private DeviceDto registerOnce(String owner, DeviceRegistrationDto request) + 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. 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 index 88c0e1eddbf..691d76383e4 100644 --- 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 @@ -26,11 +26,13 @@ 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.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.Payee; 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; @@ -54,8 +56,9 @@ /// 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** (fiat only; see [#checkLimits]): per movement, per 24 -/// hours, and per new payee during its cooling-off period. +/// 4. **The limits** ([#checkLimits]): per movement, per 24 hours, and per +/// new payee during its cooling-off period -- fiat in its 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 @@ -75,9 +78,14 @@ public class Money { private final long coolOffMillis; private final long coolOffLimit; private final boolean enforceDevices; + private final Markets markets; + private final long cryptoPerMove; + private final long cryptoDaily; public Money(Ledger ledger, Audit audit, TransferRepository transfers, - JournalEntryRepository entries, ProfileRepository profiles, + JournalEntryRepository entries, ProfileRepository profiles, Markets markets, + @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, @@ -98,6 +106,11 @@ public Money(Ledger ledger, Audit audit, TransferRepository transfers, 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()); } public long coolOffMillis() { @@ -108,10 +121,19 @@ public boolean enforcesDevices() { return enforceDevices; } - /// Whether a movement of `amount` that does or does not leave the caller - /// must be signed. - public boolean needsSignature(long amount, boolean leaves) { - return amount > stepUp || (leaves && 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()) ? amount + : 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. @@ -146,6 +168,17 @@ public ReceiptDto withdraw(String owner, String key, LedgerAccount from, long am return move(new Move(owner, key, "WITHDRAWAL", from, cash, "", amount, true, memo, null)); } + /// Coins out of the bank to an allow-listed address: into SYS_CHAIN, + /// standing for the blockchain transaction a real exchange would + /// broadcast. The caller has checked the address is the owner's, for this + /// asset, and past its cooling-off. + public ReceiptDto withdrawCrypto(String owner, String key, LedgerAccount from, + CryptoAddress address, long amount) throws IOException { + LedgerAccount chain = ledger.system(from.getAsset(), AccountKinds.SYS_CHAIN); + return move(new Move(owner, key, "CRYPTO_OUT", from, chain, address.getId(), amount, + true, "to " + address.getAddress(), null)); + } + /// 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 { @@ -234,14 +267,17 @@ private ReceiptDto moveOnce(Move m) throws IOException { return receipt(t); } - /// The limits, in ledger units of the asset, applied to fiat only. A coin - /// is not a cent: a real exchange states its crypto limits in a reference - /// currency at the current price, which needs the prices phase 4 brings. - /// Until then a crypto withdrawal is held by the signature and the trusted - /// device alone. + /// 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(); @@ -267,6 +303,35 @@ private void checkLimits(Move m) throws IOException { } } + 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 new ResponseStatusException(409, "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(), now - DAY); + if (moved != null && moved.longValue() > 0) { + sent = saturatedAdd(sent, markets.value(coin.getCode(), moved.longValue(), + "USD")); + } + } + } + if (sent > cryptoDaily) { + throw new ResponseStatusException(409, "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()); 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/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/TradeOrderRepository.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TradeOrderRepository.java new file mode 100644 index 00000000000..e5d48efb311 --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TradeOrderRepository.java @@ -0,0 +1,53 @@ +/* + * 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); + + @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..b51bfa82f6d --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/repository/TradeRepository.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.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); +} 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/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..0bb916109c8 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/ExchangeTest.java @@ -0,0 +1,366 @@ +/* + * 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.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.UUID; +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 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-" + UUID.randomUUID().toString().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 UUID.randomUUID().toString(); + } + + 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 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, 5000000L, QTY); + // 5,000 at the limit and the most fee it could pay, 10. + assertEquals(5010, balance(buyer, "USD", AccountKinds.HOLD)); + assertEquals(100000 - 5010, 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, 5000000L, + QTY, 0L); + OrderDto first = exchange.place(buyer, k, order); + OrderDto again = exchange.place(buyer, k, order); + assertEquals(first.id(), again.id()); + assertEquals(5010, balance(buyer, "USD", AccountKinds.HOLD)); + assertStatus(422, () -> exchange.place(buyer, k, new NewOrderDto("T8-USD", Exchange.BUY, + Exchange.LIMIT, 5000000L, 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 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/MoneyTest.java b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/MoneyTest.java index fce62967414..cd4aec862d1 100644 --- 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 @@ -19,6 +19,7 @@ package com.codenameone.examples.bastion; import com.codename1.backend.Base64Url; +import com.codename1.backend.DataSource; import com.codename1.backend.Json; import com.codename1.backend.annotations.Autowired; import com.codename1.backend.security.crypto.Der; @@ -26,7 +27,12 @@ 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.security.KeyPair; @@ -51,24 +57,32 @@ /// 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 JwkSource signingKeys; + + @Autowired + private DataSource db; + @Autowired private Audit audit; /// A customer signed in, with a registered device. private final class Customer { final String name; - final String token; + String token; final String checking; KeyPair key; String device; Customer(boolean rooted) throws Exception { name = SignIn.register(mvc, "m" + UUID.randomUUID().toString().substring(0, 12) - + "@bastion.example", "a long passphrase for money"); - token = SignIn.token(mvc, name, "a long passphrase for money"); + + "@bastion.example", PASSWORD); + token = SignIn.token(mvc, name, PASSWORD); checking = String.valueOf(((Map) accounts().get(0)).get("id")); key = newKey(); device = register(key, rooted); @@ -77,7 +91,8 @@ private final class Customer { String register(KeyPair pair, boolean rooted) throws Exception { MockResponse answer = send(json("/api/devices", "{\"publicKey\":\"" + Base64Url.encode(pair.getPublic().getEncoded()) + "\",\"platform\":\"and\"," - + "\"rooted\":" + rooted + ",\"attested\":true,\"integrityToken\":\"t\"}")); + + "\"rooted\":" + rooted + ",\"attested\":true,\"integrityToken\":\"t\"," + + "\"password\":\"" + PASSWORD + "\"}")); assertEquals(200, answer.getStatus(), answer.getContentAsString()); Map device = Json.parseObject(answer.getContentAsString()); assertEquals(Boolean.valueOf(!rooted), device.get("trusted")); @@ -364,4 +379,80 @@ void aFrozenCardIsDeclined() throws Exception { 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 { + 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)); + return new DefaultJwtEncoder(signingKeys).encode(JwtEncoderParameters.from(b.build())) + .getTokenValue(); + } + + @Test + void addingADeviceNeedsAFreshSignInAndThePassword() throws Exception { + Customer c = new Customer(false); + String key = Base64Url.encode(newKey().getPublic().getEncoded()); + String noPassword = "{\"publicKey\":\"" + key + "\",\"platform\":\"and\"," + + "\"rooted\":false,\"attested\":true,\"integrityToken\":\"t\"}"; + assertEquals(403, c.send(json("/api/devices", noPassword)).getStatus()); + String wrong = noPassword.replace("}", ",\"password\":\"not my password at all\"}"); + assertEquals(403, c.send(json("/api/devices", wrong)).getStatus()); + String right = noPassword.replace("}", ",\"password\":\"" + PASSWORD + "\"}"); + // A token whose sign-in is half an hour old is refused even with the + // password: it is what a stolen, refreshed token looks like. + String fresh = c.token; + c.token = aged(fresh, 30); + assertEquals(403, c.send(json("/api/devices", right)).getStatus()); + c.token = aged(fresh, 1); + assertEquals(200, c.send(json("/api/devices", right)).getStatus()); + assertTrue(audit.verify().isEmpty()); + } + + @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"); + 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()); + } } 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/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/DeviceRegistrationDto.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/DeviceRegistrationDto.java index ce4abd81076..9d5b23358e2 100644 --- 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 @@ -23,6 +23,11 @@ /// 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) { +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/ExchangeApi.java b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ExchangeApi.java new file mode 100644 index 00000000000..1adecb25dc4 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/ExchangeApi.java @@ -0,0 +1,86 @@ +/* + * 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. + @POST("/api/exchange/orders") + void place(@Header("Idempotency-Key") String idempotencyKey, @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); + + @DELETE("/api/crypto/addresses/{id}") + void removeAddress(@Path("id") String id, OnComplete> callback); + + /// Coins out of wallet `id` to an allow-listed address. Always signed. + @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/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/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/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/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) { +} From cd9f8d7e4cf3c07b9046004a1c26fbf09f5190f9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:34:37 +0300 Subject: [PATCH 05/53] Bastion sample: app foundation -- guarded network, PKCE sign-in with second factor and passkeys, device key and request signing, app lock, Security Center Re-adds the common module. Every request passes one NetworkGuard (no cleartext but the loopback dev server, a host allow-list, bundled pins, AppShield delegated to). The device key is P-256 in portable Java (the framework generates no EC keys and the iOS port signs RSA only), sealed in a SecureEnvelope under a SecureStorage key, and signs the canonical form now shared with the server (api/SignedRequest). Biometric lock on start, resume and idle; secure screens, tapjacking blocking and clipboard clearing; a Security Center that reports each protection honestly. Co-Authored-By: Claude Opus 5.5 --- .../bastion/money/RequestSigning.java | 48 +- .../examples/bastion/SigningVectorTest.java | 44 + .../common/codenameone_settings.properties | 56 + scripts/bastion/common/icon.png | Bin 0 -> 123269 bytes scripts/bastion/common/pom.xml | 1375 +++++++++++++++++ .../src/desktop/resources/NativeTheme.res | Bin 0 -> 300158 bytes scripts/bastion/common/src/main/css/theme.css | 260 ++++ .../examples/bastion/AppConfig.java | 201 +++ .../codenameone/examples/bastion/Bastion.java | 103 ++ .../codenameone/examples/bastion/net/Api.java | 60 + .../examples/bastion/net/Guard.java | 187 +++ .../codenameone/examples/bastion/net/Net.java | 102 ++ .../examples/bastion/net/Session.java | 534 +++++++ .../examples/bastion/security/DeviceKey.java | 178 +++ .../examples/bastion/security/Lock.java | 167 ++ .../examples/bastion/security/P256.java | 253 +++ .../examples/bastion/security/Protect.java | 84 + .../bastion/security/RequestSigner.java | 92 ++ .../bastion/security/SecurityStatus.java | 249 +++ .../examples/bastion/ui/HomeForm.java | 87 ++ .../codenameone/examples/bastion/ui/Lang.java | 131 ++ .../examples/bastion/ui/LockForm.java | 48 + .../bastion/ui/SecurityCenterForm.java | 94 ++ .../examples/bastion/ui/SignInForm.java | 84 + .../codenameone/examples/bastion/ui/Ui.java | 102 ++ .../common/src/main/l10n/Bundle.properties | 3 + .../examples/bastion/net/GuardTest.java | 50 + .../examples/bastion/security/P256Test.java | 81 + .../bastion/security/RequestSignerTest.java | 94 ++ .../bastion/security/SecurityStatusTest.java | 47 + scripts/bastion/pom.xml | 2 +- .../examples/bastion/api/SignedRequest.java | 110 ++ 32 files changed, 4886 insertions(+), 40 deletions(-) create mode 100644 scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/SigningVectorTest.java create mode 100644 scripts/bastion/common/codenameone_settings.properties create mode 100644 scripts/bastion/common/icon.png create mode 100644 scripts/bastion/common/pom.xml create mode 100644 scripts/bastion/common/src/desktop/resources/NativeTheme.res create mode 100644 scripts/bastion/common/src/main/css/theme.css create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/AppConfig.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/Bastion.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Api.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Guard.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Net.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Session.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/DeviceKey.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Lock.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/P256.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Protect.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/RequestSigner.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/SecurityStatus.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/HomeForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Lang.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/LockForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SecurityCenterForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SignInForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Ui.java create mode 100644 scripts/bastion/common/src/main/l10n/Bundle.properties create mode 100644 scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.java create mode 100644 scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/P256Test.java create mode 100644 scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/RequestSignerTest.java create mode 100644 scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/SecurityStatusTest.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/SignedRequest.java 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 index d5c91c10bff..11ade9e0a52 100644 --- 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 @@ -26,6 +26,7 @@ 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; @@ -67,10 +68,10 @@ /// the request's transaction so the record survives the refusal. @Component public class RequestSigning { - public static final String HEADER = "Bastion-Signature"; + 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 = 120000L; + public static final long SKEW_MILLIS = SignedRequest.SKEW_MILLIS; private final Devices devices; private final UsedNonceRepository nonces; @@ -109,7 +110,7 @@ private String check(String owner) throws IOException { if (header == null || header.length() == 0) { return "missing"; } - if (!header.startsWith("v1;")) { + if (!header.startsWith(SignedRequest.SCHEME + ";")) { return "unknown scheme"; } String deviceId = field(header, "device"); @@ -133,7 +134,7 @@ private String check(String owner) throws IOException { if (at < now - SKEW_MILLIS || at > now + SKEW_MILLIS) { return "clock skew"; } - if (!nonceShaped(nonce)) { + if (!SignedRequest.nonceShaped(nonce)) { return "malformed"; } byte[] signature = Devices.decode(sig); @@ -142,9 +143,9 @@ private String check(String owner) throws IOException { return "bad signature"; } byte[] body = request.getBodyBytes(); - String signed = request.getMethod() + "|" + request.getTarget() + "|" - + Ids.hex(Crypto.sha256(body == null ? new byte[0] : body)) + "|" + nonce + "|" - + ts; + // 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), nonce, at); boolean valid; try { valid = Crypto.verify("ES256", key, signed.getBytes("UTF-8"), @@ -195,39 +196,8 @@ private boolean nonceKnown(String id) throws IOException { return nonces.findById(id) != null; } - private static boolean nonceShaped(String nonce) { - if (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; - } - - /// The value of `name=` among the header's `;`-separated fields, or null. 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; + return SignedRequest.field(header, name); } private static HttpServer.Request request() { 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..f582fb14e89 --- /dev/null +++ b/scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/SigningVectorTest.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; + +import com.codenameone.examples.bastion.api.SignedRequest; +import org.junit.jupiter.api.Test; + +import java.security.MessageDigest; + +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 = MessageDigest.getInstance("SHA-256").digest(body.getBytes("UTF-8")); + assertEquals("POST|/api/accounts/a1/withdraw|" + + "27faf49a231437b9d9308db1799c1ab06d8665c087da17478d59acbefa20a125|" + + "AAECAwQFBgcICQoLDA0ODw|1767225600000", + SignedRequest.canonical("POST", "/api/accounts/a1/withdraw", digest, + "AAECAwQFBgcICQoLDA0ODw", 1767225600000L)); + assertEquals("dev1", SignedRequest.field( + "v1;device=dev1;ts=1;nonce=n;sig=s", "device")); + } +} 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 0000000000000000000000000000000000000000..1f4fa5dd25258fbaa99b15a11d47c83ce4a9a8ac GIT binary patch literal 123269 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

g@MUJI7HpSpMR*={}r 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 + + 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 0000000000000000000000000000000000000000..83e067b6969553d6d2cdd7257e64e7417393864f GIT binary patch literal 300158 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 zü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 BseS+e literal 0 HcmV?d00001 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..29e39afcaca --- /dev/null +++ b/scripts/bastion/common/src/main/css/theme.css @@ -0,0 +1,260 @@ +/* + * 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; +} + +/* ---------------------------------------------------------------- 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; + 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: 1mm; + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 0.6mm 1.6mm 0.6mm 1.6mm; + margin: 0 0 0 1.6mm; +} + +BnBadgeWarn { + color: var(--warning); + background-color: var(--warning-tint); + border-radius: 1mm; + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 0.6mm 1.6mm 0.6mm 1.6mm; + margin: 0 0 0 1.6mm; +} + +BnBadgeBad { + color: var(--danger); + background-color: var(--danger-tint); + border-radius: 1mm; + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 0.6mm 1.6mm 0.6mm 1.6mm; + 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.9mm; + text-align: center; + padding: 2.75mm 4mm 2.75mm 4mm; + margin: 1.6mm 0 0.8mm 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.9mm; + text-align: center; + padding: 2.75mm 4mm 2.75mm 4mm; + margin: 1.6mm 0 0.8mm 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; +} + +/* --------------------------------------------------------------- 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); } + 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); } +} 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..9783919eaca --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/AppConfig.java @@ -0,0 +1,201 @@ +/* + * 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; + +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 the server is when nothing says otherwise: a server started on + /// this machine with `cn1:backend`. A release points this at an `https` + /// address; see [#isDevelopmentServer]. + public static final String DEFAULT_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 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 static final String SERVER_KEY = "bastion.server"; + + private AppConfig() { + } + + /// The server's address with no trailing slash: a `bastion.server.url` + /// system property (the tests), then the one chosen in the app, then + /// [#DEFAULT_SERVER]. + public static String serverUrl() { + String url = System.getProperty("bastion.server.url"); + if (url == null || url.length() == 0) { + url = Preferences.get(SERVER_KEY, DEFAULT_SERVER); + } + return clean(url); + } + + /// Points the app at another server. Refused unless the address is + /// an `https` address of a host in [#ALLOWED_HOSTS], or the development + /// server on this machine: the app is never talked into plain HTTP, or + /// into a server nobody listed, from a settings screen. + public static boolean setServerUrl(String url) { + String cleaned = clean(url); + if (!isDevelopmentServer(cleaned) + && (!isHttps(cleaned) || !isAllowedHost(hostOf(cleaned)))) { + return false; + } + Preferences.set(SERVER_KEY, cleaned); + return true; + } + + /// Whether `url` is a server on this machine: `localhost` or a loopback + /// address. The ONE exemption from "no cleartext": a development 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. + public static boolean isDevelopmentServer(String url) { + String host = hostOf(url); + return "localhost".equals(host) || "127.0.0.1".equals(host) || "[::1]".equals(host) + || "10.0.2.2".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; + } + int end = start; + boolean bracket = end < url.length() && url.charAt(end) == '['; + while (end < url.length()) { + char c = url.charAt(end); + if (c == '/' || c == '?' || c == '#' || (!bracket && c == ':')) { + break; + } + if (bracket && c == ']') { + end++; + break; + } + end++; + } + int at = url.lastIndexOf('@', end); + if (at >= start) { + start = at + 1; + } + return asciiLower(url.substring(start, end)); + } + + /// [#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 DEFAULT_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..e49baf7b162 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/Bastion.java @@ -0,0 +1,103 @@ +/* + * 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.net.Guard; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.security.Lock; +import com.codenameone.examples.bastion.security.Protect; +import com.codenameone.examples.bastion.ui.HomeForm; +import com.codenameone.examples.bastion.ui.Lang; +import com.codenameone.examples.bastion.ui.LockForm; +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(); + Guard.install(); + Protect.install(); + Session.start(AppConfig.serverUrl()); + Session.onSignedOut(Bastion::signInScreen); + Lock.install(new Lock.Screens() { + @Override + public void showLocked() { + LockForm.show(() -> Session.signOut(Bastion::signInScreen)); + } + + @Override + public void showUnlocked(Form previous) { + if (previous != null) { + Ui.show(previous, true); + } else { + home(); + } + } + }); + Session.restore(() -> { + // A stored session opens behind the lock: whoever holds the phone + // proves they are its owner first. + if (Lock.biometricsAvailable()) { + Lock.lock(); + } else { + home(); + } + }, Bastion::signInScreen, Bastion::signInScreen); + } + + @Override + public void start() { + super.start(); + Lock.resumed(); + } + + @Override + public void stop() { + super.stop(); + Lock.paused(); + } + + static void signInScreen() { + 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(); + home(); + }); + } + + static void home() { + HomeForm.show(Bastion::signInScreen); + } +} 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..c3b61bf7c34 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Api.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.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.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 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); + } + + public static AccountApi account() { + return account; + } + + public static BankingApi banking() { + return banking; + } + + public static ExchangeApi exchange() { + return exchange; + } + + public static AdminApi admin() { + return admin; + } +} 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..f9c6ca7ae73 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Guard.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.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(); + + private Guard() { + } + + /// Installs the guard and starts the shield. Called once, before the first + /// request. + public static void install() { + if (installed != null) { + return; + } + installed = new Guard(); + try { + NetworkManager.setNetworkGuard(installed); + } catch (IllegalStateException alreadyInstalled) { + Log.p("Bastion: a network guard was already installed; requests are not guarded"); + } + 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); + } + + 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()); + // An empty chain is "not seen" (the iOS TLS cache), not a mismatch: + // the same never-brick rule the shield follows. + if (host != null && pins.isEnforcedFor(host) && certificates != null + && certificates.length > 0) { + 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..30b74b53b9f --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Net.java @@ -0,0 +1,102 @@ +/* + * 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.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 { + /// 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. + public static OnComplete> to(final Ok ok, final Failed failed) { + return new OnComplete>() { + @Override + public void completed(Response response) { + if (response == null) { + failed.failed(0, "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) { + Object body = response.getResponseData(); + if (body instanceof String && ((String) body).length() > 0 + && ((String) body).length() < 200 && ((String) body).indexOf('<') < 0 + && ((String) body).indexOf('{') < 0) { + return (String) body; + } + if (status == 401) { + return "Please sign in again."; + } + if (status == 403) { + return "Your account is not allowed to do that."; + } + if (status == 429) { + return "Too many attempts. Wait a moment and try again."; + } + return Lang.tr("Something went wrong ({0}). Try again.", String.valueOf(status)); + } +} 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..ca3409770da --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Session.java @@ -0,0 +1,534 @@ +/* + * 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.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.io.oidc.TokenStore; +import com.codename1.io.webauthn.PublicKeyCredential; +import com.codename1.io.webauthn.PublicKeyCredentialRequestOptions; +import com.codename1.io.webauthn.WebAuthnClient; +import com.codename1.ui.CN; +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. +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; + + 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) { + // 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 SecureStorageTokenStore().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.REDIRECT_URI) + .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(); + } + + /// Keeps the tokens somewhere else (the tests keep them in memory). + public static void useStore(TokenStore tokens) { + store = tokens; + } + + public static void onSignedOut(Runnable handler) { + onSignedOut = handler; + } + + public static String serverUrl() { + return base; + } + + public static UserDto user() { + return user; + } + + /// 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()); + } + + /// 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.session != null && login.status == 302 && login.location != null + && login.location.indexOf("/login/mfa") >= 0) { + needsCode.got(new Pending(server, login.session)); + } else if (login.session != null && login.status == 200) { + 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.addRequestHeader("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 == 200) { + // 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 || 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.addRequestHeader("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.addRequestHeader("Cookie", cookie); + authorize.addArgument("response_type", "code"); + authorize.addArgument("client_id", AppConfig.CLIENT_ID); + authorize.addArgument("redirect_uri", AppConfig.REDIRECT_URI); + authorize.addArgument("scope", "openid profile"); + authorize.addArgument("state", state); + authorize.addArgument("code_challenge", pkce.getChallenge()); + authorize.addArgument("code_challenge_method", pkce.getMethod()); + authorize.send(() -> { + String code = parameter(authorize.location, "code"); + // A code that comes back with another state was not issued for + // this sign-in. + if (!server.equals(base) || code == null + || !state.equals(parameter(authorize.location, "state"))) { + 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.REDIRECT_URI); + 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. + Step end = new Step("POST", server + "/logout"); + end.addRequestHeader("Cookie", cookie); + end.send(() -> { }); + session++; + authorizer.setTokens(tokens); + store.save(storeKey(base), tokens); + refreshUser(done, failed); + }); + }); + } + + /// 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) { + 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); + } + + 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..817b3604f4c --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/DeviceKey.java @@ -0,0 +1,178 @@ +/* + * 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.SecureRandom; +import com.codename1.security.SecureStorage; +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.DeviceRegistrationDto; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; + +/// This device's signing key: the second thing, after the session, that the +/// server asks for before money leaves an account. +/// +/// The private key is a P-256 scalar ([P256]). 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 +/// a 256-bit wrapping key that lives in [SecureStorage] -- the iOS Keychain or +/// the Android Keystore-backed store. The sealed blob alone, in the app's +/// preferences, is useless off the device. +/// +/// In the simulator `SecureStorage` is an ordinary file, and the Security +/// Center reports the key as not hardware protected there. +public final class DeviceKey { + private static final String WRAP_ACCOUNT = "bastion.device.wrap"; + 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"; + + private static byte[] 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; + } + + /// 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 byte[] privateKey() { + if (Lock.isLocked()) { + return null; + } + if (cached != null) { + return cached; + } + String sealed = Preferences.get(SEALED_KEY, ""); + String owner = owner(); + byte[] wrap = wrapKey(false); + if (sealed.length() == 0 || owner == null || wrap == null) { + return null; + } + try { + cached = 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) { + final byte[] key = P256.newPrivateKey(SecureRandom.bytes(48)); + final String publicKey = Base64.encodeUrlSafe(P256.publicKey(key)); + 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 byte[] 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); + Api.banking().registerDevice(request, Net.to(device -> { + byte[] wrap = wrapKey(true); + if (wrap == null) { + failed.failed(0, "This device has no secure storage for the key."); + return; + } + byte[] sealed = SecureEnvelope.seal(wrap, "device-key", 1, aad(owner), key); + Preferences.set(SEALED_KEY, Base64.encodeUrlSafe(sealed)); + Preferences.set(ID_KEY, device.id()); + Preferences.set(OWNER_KEY, owner); + cached = key; + done.got(device); + }, failed)); + } + + private static AssociatedData aad(String owner) { + return AssociatedData.of("bastion", "device", owner, "request-signing"); + } + + /// The wrapping key from secure storage, made on first use when `create`. + private static byte[] wrapKey(boolean create) { + SecureStorage storage = SecureStorage.getInstance(); + String stored = storage.get(WRAP_ACCOUNT); + if (stored == null && create) { + // setIfAbsent: two starts racing make one key, not two. + stored = storage.setIfAbsent(WRAP_ACCOUNT, + Base64.encodeUrlSafe(SecureRandom.bytes(32))); + } + if (stored == null) { + return null; + } + byte[] key = Base64.decodeUrlSafe(stored); + return key != null && key.length == 32 ? key : null; + } +} 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..117b0bc28d5 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Lock.java @@ -0,0 +1,167 @@ +/* + * 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.AuthenticationOptions; +import com.codename1.security.Biometrics; +import com.codename1.ui.CN; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.util.UITimer; +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: a biometric check 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 unsealed device key is dropped from memory, so nothing can +/// sign a payment until [DeviceKey] unseals it again after the check. Where the device has no biometrics, or the user has none +/// enrolled, the fallback is the password: the lock 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 UITimer idle; + + private Lock() { + } + + public static void install(Screens app) { + screens = app; + if (idle == null) { + idle = UITimer.timer(5000, true, Lock::checkIdle); + } + } + + /// Called by every form the app shows: a touch anywhere is activity. + public static void watch(Form form) { + form.addPointerPressedListener(evt -> touched()); + form.addPointerDraggedListener(evt -> touched()); + } + + 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(); + hidden = CN.getCurrentForm(); + screens.showLocked(); + } + + /// The app went to the background. + public static void paused() { + backgroundSince = System.currentTimeMillis(); + } + + /// 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 for the biometric check. `fallback` runs when there is none to + /// ask for; `failed` when the user cancelled or failed it. + public static void unlock(final Runnable fallback, final Runnable failed) { + if (!biometricsAvailable()) { + fallback.run(); + return; + } + AuthenticationOptions options = new AuthenticationOptions() + .setTitle(Lang.tr("Unlock Bastion")) + .setReason(Lang.tr("Unlock Bastion and the keys that sign your payments")) + .setNegativeButtonText(Lang.tr("Cancel")) + // Only a biometric: the device passcode is a second way in for + // anyone who watched it typed. + .setBiometricOnly(true) + .setSensitiveTransaction(true); + Biometrics.getInstance().authenticate(options).ready(new SuccessCallback() { + @Override + public void onSucess(Boolean ok) { + if (ok != null && ok.booleanValue()) { + unlocked(); + } else { + failed.run(); + } + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + failed.run(); + } + }); + } + + /// 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/P256.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/P256.java new file mode 100644 index 00000000000..a5966fe863a --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/P256.java @@ -0,0 +1,253 @@ +/* + * 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.Hash; +import com.codename1.security.Hmac; +import com.codename1.util.BigInteger; + +/// ECDSA over NIST P-256 with SHA-256 (ES256), in portable Java. +/// +/// The device key is P-256 because that is what the server accepts and what +/// every phone's secure hardware can hold. The framework's +/// `com.codename1.security.KeyGenerator` makes RSA keys only, and the iOS +/// port's `Signature` signs with RSA keys only, so the sample does the curve +/// arithmetic itself: identical on every port, and checked against the JDK +/// and the RFC 6979 vectors by `P256Test`. +/// +/// Signing uses RFC 6979 deterministic nonces (HMAC-SHA-256), so a weak +/// random generator cannot leak the key through a repeated `k`. This is not +/// constant time: a sample's key on a phone is not exposed to a timing +/// attacker the way a server's is, but a production app should move the key +/// into the platform keystore (non-exportable, hardware backed) once the +/// framework can generate EC keys there. +public final class P256 { + static final BigInteger P = hex( + "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff"); + static final BigInteger N = hex( + "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"); + static final BigInteger B = hex( + "5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b"); + static final BigInteger GX = hex( + "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296"); + static final BigInteger GY = hex( + "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5"); + private static final BigInteger THREE = BigInteger.valueOf(3); + + /// The DER of a P-256 SubjectPublicKeyInfo up to the point. + private static final byte[] SPKI_PREFIX = Hash.fromHex( + "3059301306072a8648ce3d020106082a8648ce3d030107034200"); + + private P256() { + } + + /// A fresh private key: 32 bytes, a scalar in `[1, n-1]`. + public static byte[] newPrivateKey(byte[] seed) { + // Reduced from 48 bytes so the bias of the modulo is negligible. + if (seed == null || seed.length < 48) { + throw new IllegalArgumentException("48 random bytes are needed"); + } + BigInteger d = new BigInteger(1, seed).mod(N.subtract(BigInteger.ONE)).add(BigInteger.ONE); + return fixed(d); + } + + /// The public key of `privateKey` as X.509 SubjectPublicKeyInfo DER, the + /// form the server takes (`DeviceRegistrationDto.publicKey`, base64url). + public static byte[] publicKey(byte[] privateKey) { + BigInteger[] q = toAffine(multiply(scalar(privateKey), GX, GY)); + byte[] out = new byte[SPKI_PREFIX.length + 65]; + System.arraycopy(SPKI_PREFIX, 0, out, 0, SPKI_PREFIX.length); + out[SPKI_PREFIX.length] = 4; + System.arraycopy(fixed(q[0]), 0, out, SPKI_PREFIX.length + 1, 32); + System.arraycopy(fixed(q[1]), 0, out, SPKI_PREFIX.length + 33, 32); + return out; + } + + /// Signs `message` (hashed here with SHA-256) and answers the 64-byte + /// `r || s`. + public static byte[] sign(byte[] privateKey, byte[] message) { + BigInteger d = scalar(privateKey); + byte[] digest = Hash.sha256(message); + BigInteger e = new BigInteger(1, digest); + Rfc6979 nonces = new Rfc6979(fixed(d), fixed(e.mod(N))); + while (true) { + BigInteger k = nonces.next(); + BigInteger[] point = toAffine(multiply(k, GX, GY)); + BigInteger r = point[0].mod(N); + if (r.signum() == 0) { + continue; + } + BigInteger s = inverse(k, N).multiply(e.add(r.multiply(d))).mod(N); + if (s.signum() == 0) { + continue; + } + byte[] out = new byte[64]; + System.arraycopy(fixed(r), 0, out, 0, 32); + System.arraycopy(fixed(s), 0, out, 32, 32); + return out; + } + } + + private static BigInteger scalar(byte[] privateKey) { + if (privateKey == null || privateKey.length != 32) { + throw new IllegalArgumentException("A P-256 private key is 32 bytes"); + } + BigInteger d = new BigInteger(1, privateKey); + if (d.signum() == 0 || d.compareTo(N) >= 0) { + throw new IllegalArgumentException("Not a P-256 private key"); + } + return d; + } + + // ---------------------------------------------------- curve arithmetic + // Jacobian coordinates {X, Y, Z}: x = X/Z^2, y = Y/Z^3; null is infinity. + + static BigInteger[] multiply(BigInteger k, BigInteger x, BigInteger y) { + BigInteger[] base = {x, y, BigInteger.ONE}; + BigInteger[] acc = null; + for (int bit = k.bitLength() - 1; bit >= 0; bit--) { + acc = twice(acc); + if (k.testBit(bit)) { + acc = add(acc, base); + } + } + return acc; + } + + private static BigInteger[] twice(BigInteger[] p) { + if (p == null || p[1].signum() == 0) { + return null; + } + BigInteger delta = p[2].multiply(p[2]).mod(P); + BigInteger gamma = p[1].multiply(p[1]).mod(P); + BigInteger beta = p[0].multiply(gamma).mod(P); + BigInteger alpha = THREE.multiply(p[0].subtract(delta)).multiply(p[0].add(delta)).mod(P); + BigInteger x3 = alpha.multiply(alpha).subtract(beta.shiftLeft(3)).mod(P); + BigInteger z3 = p[1].add(p[2]).pow(2).subtract(gamma).subtract(delta).mod(P); + BigInteger y3 = alpha.multiply(beta.shiftLeft(2).subtract(x3)) + .subtract(gamma.multiply(gamma).shiftLeft(3)).mod(P); + return new BigInteger[] {x3, y3, z3}; + } + + private static BigInteger[] add(BigInteger[] p, BigInteger[] q) { + if (p == null) { + return q; + } + if (q == null) { + return p; + } + BigInteger z1z1 = p[2].multiply(p[2]).mod(P); + BigInteger z2z2 = q[2].multiply(q[2]).mod(P); + BigInteger u1 = p[0].multiply(z2z2).mod(P); + BigInteger u2 = q[0].multiply(z1z1).mod(P); + BigInteger s1 = p[1].multiply(q[2]).multiply(z2z2).mod(P); + BigInteger s2 = q[1].multiply(p[2]).multiply(z1z1).mod(P); + if (u1.equals(u2)) { + return s1.equals(s2) ? twice(p) : null; + } + BigInteger h = u2.subtract(u1).mod(P); + BigInteger i = h.shiftLeft(1).pow(2).mod(P); + BigInteger j = h.multiply(i).mod(P); + BigInteger r = s2.subtract(s1).shiftLeft(1).mod(P); + BigInteger v = u1.multiply(i).mod(P); + BigInteger x3 = r.multiply(r).subtract(j).subtract(v.shiftLeft(1)).mod(P); + BigInteger y3 = r.multiply(v.subtract(x3)).subtract(s1.multiply(j).shiftLeft(1)).mod(P); + BigInteger z3 = p[2].add(q[2]).pow(2).subtract(z1z1).subtract(z2z2).multiply(h).mod(P); + return new BigInteger[] {x3, y3, z3}; + } + + static BigInteger[] toAffine(BigInteger[] p) { + if (p == null) { + throw new IllegalStateException("point at infinity"); + } + BigInteger zi = inverse(p[2], P); + BigInteger zi2 = zi.multiply(zi).mod(P); + BigInteger x = p[0].multiply(zi2).mod(P); + BigInteger y = p[1].multiply(zi2).multiply(zi).mod(P); + // y^2 = x^3 - 3x + b: a wrong result never leaves this class. + BigInteger left = y.multiply(y).mod(P); + BigInteger right = x.pow(3).subtract(THREE.multiply(x)).add(B).mod(P); + if (!left.equals(right)) { + throw new IllegalStateException("point is not on the curve"); + } + return new BigInteger[] {x, y}; + } + + /// `value` to the power -1 modulo the prime `m`, by Fermat: value^(m-2). + /// + /// Not `BigInteger.modInverse`: the framework's answers wrong for 256-bit + /// operands (it agrees with `java.math` on small numbers only), which + /// `P256Test` would catch at once. + static BigInteger inverse(BigInteger value, BigInteger m) { + return value.mod(m).modPow(m.subtract(BigInteger.valueOf(2)), m); + } + + /// `value` as exactly 32 unsigned big-endian bytes. + static byte[] fixed(BigInteger value) { + byte[] raw = value.toByteArray(); + byte[] out = new byte[32]; + int copy = Math.min(raw.length, 32); + System.arraycopy(raw, raw.length - copy, out, 32 - copy, copy); + return out; + } + + private static BigInteger hex(String text) { + return new BigInteger(text, 16); + } + + /// RFC 6979 section 3.2 with HMAC-SHA-256, for a 256-bit order. + private static final class Rfc6979 { + private byte[] v = new byte[32]; + private byte[] k = new byte[32]; + + Rfc6979(byte[] x, byte[] h1) { + for (int iter = 0; iter < 32; iter++) { + v[iter] = 1; + } + k = Hmac.sha256(k, concat(v, new byte[] {0}, x, h1)); + v = Hmac.sha256(k, v); + k = Hmac.sha256(k, concat(v, new byte[] {1}, x, h1)); + v = Hmac.sha256(k, v); + } + + BigInteger next() { + while (true) { + v = Hmac.sha256(k, v); + BigInteger candidate = new BigInteger(1, v); + if (candidate.signum() > 0 && candidate.compareTo(N) < 0) { + // Moved on now, so a retry (r or s zero) gets a new k. + k = Hmac.sha256(k, concat(v, new byte[] {0}, new byte[0], new byte[0])); + v = Hmac.sha256(k, v); + return candidate; + } + k = Hmac.sha256(k, concat(v, new byte[] {0}, new byte[0], new byte[0])); + v = Hmac.sha256(k, v); + } + } + + private static byte[] concat(byte[] a, byte[] b, byte[] c, byte[] d) { + byte[] out = new byte[a.length + b.length + c.length + d.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + System.arraycopy(c, 0, out, a.length + b.length, c.length); + System.arraycopy(d, 0, out, a.length + b.length + c.length, d.length); + return out; + } + } +} 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..8feb592d63e --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/Protect.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.security; + +import com.codename1.security.DeviceIntegrity; +import com.codename1.security.TapjackingPolicy; +import com.codename1.security.shield.ShieldSignal; +import com.codename1.security.shield.ShieldSignals; +import com.codename1.ui.Display; +import com.codename1.ui.util.UITimer; +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 or address the user copies is wiped +/// from the clipboard after [AppConfig#CLIPBOARD_CLEAR_MILLIS], unless +/// something else was copied in the meantime. +public final class Protect { + private static boolean secure; + private static String copied; + + 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")); + } + + /// Called as each form shows: `sensitive` forms block screenshots. + public static void screen(boolean sensitive) { + if (sensitive != secure) { + secure = sensitive; + DeviceIntegrity.setSecureScreen(sensitive); + } + } + + public static boolean isSecureScreen() { + return secure; + } + + /// Copies `text`, and clears it again later. + public static void copy(final String text) { + Display.getInstance().copyToClipboard(text); + copied = text; + UITimer.timer((int) AppConfig.CLIPBOARD_CLEAR_MILLIS, false, () -> { + Object now = Display.getInstance().getPasteDataFromClipboard(); + // Only what this app put there: never wipe something the user + // copied from elsewhere since. + if (now instanceof String && now.equals(text) && text.equals(copied)) { + Display.getInstance().copyToClipboard(""); + copied = null; + } + }); + } +} 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..657c667b660 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/RequestSigner.java @@ -0,0 +1,92 @@ +/* + * 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.SecureRandom; +import com.codename1.util.Base64; +import com.codenameone.examples.bastion.api.SignedRequest; + +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, 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), 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. + public static String sign(String method, String target, Object body) { + String deviceId = DeviceKey.deviceId(); + byte[] key = DeviceKey.privateKey(); + if (deviceId == null || key == null) { + return null; + } + return header(deviceId, key, method, target, 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, Object)], which the tests check against the + /// server's form. + public static String header(String deviceId, byte[] privateKey, String method, + String target, byte[] body, String nonce, long ts) { + String text = SignedRequest.canonical(method, target, Hash.sha256(body), nonce, ts); + byte[] signature = P256.sign(privateKey, utf8(text)); + return SignedRequest.header(deviceId, ts, nonce, Base64.encodeUrlSafe(signature)); + } + + /// 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/SecurityStatus.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/SecurityStatus.java new file mode 100644 index 00000000000..198622c87e3 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/SecurityStatus.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.security; + +import com.codename1.security.DeviceIntegrity; +import com.codename1.security.SecureStorage; +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. + public static final class Item { + public final String group; + public final String name; + public final int state; + public final String detail; + + Item(String group, String name, int state, String detail) { + this.group = group; + this.name = name; + this.state = state; + this.detail = detail; + } + } + + 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 " + server + + " is exempt: plain HTTP to this machine only") + : new Item(g, "No cleartext", ACTIVE, "Every request is https; http is 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 " + 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.hostCount() + " hosts, version " + + ota.getVersion() + (ota.isStale() ? " (refresh due)" : ""))); + out.add(AppShield.isProtected() + ? new Item(g, "App attestation token", ACTIVE, "AppShield engine " + + 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(DeviceKey.deviceId() != null + ? new Item(g, "Device signing key", ACTIVE, "P-256, registered as " + + DeviceKey.deviceId() + "; signs every payment") + : 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(Lock.biometricsAvailable() + ? new Item(g, "Biometric lock", ACTIVE, "On start, on return and when idle") + : new Item(g, "Biometric lock", sim ? SIMULATOR : WARNING, + "No biometrics enrolled: the lock asks for the password instead")); + out.add(new Item(g, "Auto-lock", ACTIVE, "After " + (AppConfig.IDLE_LOCK_MILLIS / 1000) + + "s idle or " + (AppConfig.BACKGROUND_LOCK_MILLIS / 1000) + "s in the background")); + 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: " + + 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 " + + (AppConfig.CLIPBOARD_CLEAR_MILLIS / 1000) + "s")); + + // ------------------------------------------------------------- build + g = "Build hardening"; + out.add(Hardening.isHardened() + ? new Item(g, "Code hardening", ACTIVE, "Level " + Hardening.getLevel() + + ", mapping " + Hardening.getMappingId()) + : new Item(g, "Code hardening", CLOUD_BUILD, + "Declared " + hint("cn1.hardenLevel") + "; applied by the build server")); + out.add(new Item(g, "Jailbreak exit (iOS)", ios && !sim ? ACTIVE : CLOUD_BUILD, + "ios.detectJailbreak=" + hint("ios.detectJailbreak"))); + out.add(new Item(g, "App switcher blanking (iOS)", ios && !sim ? ACTIVE : CLOUD_BUILD, + "ios.blockScreenshotsOnEnterBackground=" + + hint("ios.blockScreenshotsOnEnterBackground"))); + out.add(new Item(g, "Play Integrity (Android)", android && !sim ? ACTIVE : CLOUD_BUILD, + "android.playIntegrity=" + 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 new Item(g, "Certificate pinning", ACTIVE, AppConfig.PINS.length + " hosts pinned" + + (guard == null || guard.pinFailureCount() == 0 ? "" + : ", " + guard.pinFailureCount() + " mismatches refused")); + } + + 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"); + } + + 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: " + + join(DeviceIntegrity.getCompromiseReasons()) + + ". The bank will not move money from it."); + } + return new Item(g, "Root and jailbreak checks", ACTIVE, "No signs of root or jailbreak"); + } + + private static String refusals(Guard guard) { + int n = guard == null ? 0 : guard.refusedCount(); + return n == 0 ? "" : " (" + n + " refused)"; + } + + 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/HomeForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/HomeForm.java new file mode 100644 index 00000000000..440f1e9e089 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/HomeForm.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.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.Command; +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.codenameone.examples.bastion.api.UserDto; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.security.DeviceKey; + +/// The signed-in home screen. The accounts, transfers, cards and exchange +/// screens arrive in the next phase; until then this is the way to the +/// Security Center, to registering this device's signing key, and out. +public final class HomeForm { + private HomeForm() { + } + + public static void show(final Runnable signedOut) { + UserDto user = Session.user(); + Form form = Ui.form("Bastion"); + form.add(Ui.title(user == null || user.displayName() == null ? "Welcome" + : user.displayName())); + form.add(Ui.muted(Lang.tr("Accounts, transfers, cards and the exchange are on their way."))); + final Label status = new Label("", "BnMuted"); + refresh(status); + form.add(status); + Button register = Ui.secondary("Register this device"); + register.addActionListener(evt -> register(status, user)); + Button center = Ui.primary("Security Center"); + center.addActionListener(evt -> SecurityCenterForm.show(() -> show(signedOut))); + Button out = Ui.secondary("Sign out"); + out.addActionListener(evt -> Session.signOut(signedOut)); + form.addAll(center, register, out); + Ui.show(form, true); + } + + private static void refresh(Label status) { + status.setText(DeviceKey.deviceId() == null + ? Lang.tr("This device cannot sign payments yet.") + : Lang.tr("This device signs your payments.")); + } + + private static void register(final Label status, UserDto user) { + final String owner = user == null ? null : user.username(); + if (owner == null) { + return; + } + String password = null; + if (!user.totpEnabled()) { + // Without an authenticator app the server wants the password as + // the second proof that the owner is adding this device. + TextField field = new TextField("", Lang.tr("Password"), 20, TextArea.PASSWORD); + Command ok = new Command(Lang.tr("Continue")); + if (Dialog.show(Lang.tr("Confirm it is you"), field, ok, + new Command(Lang.tr("Cancel"))) != ok) { + return; + } + password = field.getText(); + } + DeviceKey.register(owner, password, device -> refresh(status), + (code, message) -> { + status.setText(Lang.tr(message)); + status.getComponentForm().revalidate(); + }); + } +} 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..a5730a6a9db --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Lang.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.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 -- "Pick up Riley". 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 a line for it to +/// each of the four translated files. To add a language: add its file, and its +/// code to [#CODES]. +public final class Lang { + /// The languages there is a translation for. English is the source. + 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); + } + + /// 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..5d9dc4b6bc5 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/LockForm.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.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codenameone.examples.bastion.security.Lock; + +/// What is on screen while the app is locked: nothing of the account. +public final class LockForm { + private LockForm() { + } + + /// `signInAgain` is the way out when there is no biometric to ask for. + public static void show(final Runnable signInAgain) { + Form form = Ui.form("Locked"); + form.add(Ui.title("Bastion is locked")); + final Label error = Ui.error(); + Button unlock = Ui.primary(Lock.biometricsAvailable() ? "Unlock" : "Sign in again"); + unlock.addActionListener(evt -> ask(signInAgain, error)); + form.addAll(error, unlock); + // Locked screens are sensitive too: the last frame before the lock + // must not survive in the recent-apps list. + Ui.show(form, true); + ask(signInAgain, error); + } + + private static void ask(Runnable signInAgain, final Label error) { + Lock.unlock(signInAgain, () -> Ui.showError(error, Lang.tr("Not recognised. Try again."))); + } +} 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..0a3172eedb5 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SecurityCenterForm.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.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.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +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"); + 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)); + } + Button done = Ui.secondary("Back"); + done.addActionListener(evt -> back.run()); + form.add(done); + Ui.show(form, false); + } + + private static Container row(SecurityStatus.Item item) { + Container row = new Container(new BorderLayout(), "BnRow"); + Container words = new Container(BoxLayout.y()); + words.add(new Label(Lang.tr(item.name), "BnRowTitle")); + SpanLabel detail = new SpanLabel(item.detail); + detail.setTextUIID("BnRowDetail"); + words.add(detail); + row.add(BorderLayout.CENTER, words); + row.add(BorderLayout.EAST, new Label(Lang.tr(stateName(item.state)), stateStyle(item.state))); + return row; + } + + 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/SignInForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SignInForm.java new file mode 100644 index 00000000000..2264a5a4293 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SignInForm.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.Button; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codenameone.examples.bastion.net.Session; + +/// E-mail and password, then -- for an account with an authenticator app -- +/// the six-digit code; or a passkey instead of both. +public final class SignInForm { + private SignInForm() { + } + + public static void show(final Runnable signedIn) { + Form form = Ui.form("Sign in"); + form.add(Ui.title("Bastion")); + form.add(Ui.muted(Lang.tr("Your bank, locked down."))); + final TextField email = Ui.field("E-mail", TextArea.EMAILADDR); + final TextField password = Ui.field("Password", TextArea.PASSWORD); + final Label error = Ui.error(); + final Button go = Ui.primary("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(""); + }); + 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, false); + } + + private static void codeStep(final Session.Pending pending, final Runnable signedIn) { + Form form = Ui.form("Verification"); + form.add(Ui.title("Enter your code")); + 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.NUMERIC); + final Label 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)); + }); + }); + Ui.show(form, true); + } +} 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..5474454a651 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Ui.java @@ -0,0 +1,102 @@ +/* + * 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.Component; +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.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"); + 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. + public static void show(Form form, boolean sensitive) { + Protect.screen(sensitive); + Lock.watch(form); + form.show(); + } + + 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; + } + + public static Label error() { + Label label = new Label("", "BnError"); + label.setVisible(false); + return label; + } + + public static void showError(Label 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"); + } +} 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/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..77bc52b7c98 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.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.net; + +import com.codenameone.examples.bastion.AppConfig; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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() { + 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)")); + } +} diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/P256Test.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/P256Test.java new file mode 100644 index 00000000000..98cec447694 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/P256Test.java @@ -0,0 +1,81 @@ +/* + * 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.security.KeyFactory; +import java.security.KeyPairGenerator; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// The curve arithmetic against RFC 6979 and the JDK. +class P256Test { + private static byte[] hex(String text) { + byte[] out = new byte[text.length() / 2]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) Integer.parseInt(text.substring(iter * 2, iter * 2 + 2), 16); + } + return out; + } + + /// RFC 6979 A.2.5, P-256 with SHA-256, message "sample". + @Test + void rfc6979Vector() throws Exception { + byte[] x = hex("c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721"); + byte[] spki = P256.publicKey(x); + assertArrayEquals(hex("60fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6"), + Arrays.copyOfRange(spki, spki.length - 64, spki.length - 32)); + assertArrayEquals(hex("7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299"), + Arrays.copyOfRange(spki, spki.length - 32, spki.length)); + byte[] sig = P256.sign(x, "sample".getBytes("UTF-8")); + assertArrayEquals(hex("efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716" + + "f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8"), sig); + } + + /// A key the JDK made has the public point this class computes, and the + /// SubjectPublicKeyInfo this class writes is one the JDK reads. + @Test + void agreesWithTheJdk() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + for (int round = 0; round < 5; round++) { + java.security.KeyPair pair = generator.generateKeyPair(); + byte[] d = toFixed(((ECPrivateKey) pair.getPrivate()).getS().toByteArray()); + byte[] mine = P256.publicKey(d); + assertArrayEquals(pair.getPublic().getEncoded(), mine); + ECPublicKey read = (ECPublicKey) KeyFactory.getInstance("EC") + .generatePublic(new X509EncodedKeySpec(mine)); + assertEquals(((ECPublicKey) pair.getPublic()).getW(), read.getW()); + } + } + + private static byte[] toFixed(byte[] raw) { + byte[] out = new byte[32]; + int copy = Math.min(raw.length, 32); + System.arraycopy(raw, raw.length - copy, out, 32 - copy, copy); + return out; + } +} 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..f2085eca935 --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/RequestSignerTest.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.security; + +import com.codenameone.examples.bastion.api.SignedRequest; +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.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 CANONICAL = "POST|/api/accounts/a1/withdraw|" + + "27faf49a231437b9d9308db1799c1ab06d8665c087da17478d59acbefa20a125|" + + 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, NONCE, TS)); + // 16 bytes in base64url, the length RequestSigner.nonce() makes. + assertTrue(SignedRequest.nonceShaped(NONCE)); + } + + @Test + void headerVerifiesWithTheJdkAsTheServerDoes() throws Exception { + byte[] seed = new byte[48]; + for (int iter = 0; iter < seed.length; iter++) { + seed[iter] = (byte) (iter * 7 + 3); + } + byte[] privateKey = P256.newPrivateKey(seed); + String header = RequestSigner.header("dev1", privateKey, "POST", + "/api/accounts/a1/withdraw", 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(P256.publicKey(privateKey))); + Signature verifier = Signature.getInstance("SHA256withECDSA"); + verifier.initVerify(key); + verifier.update(CANONICAL.getBytes("UTF-8")); + assertTrue(verifier.verify(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/pom.xml b/scripts/bastion/pom.xml index 1790ab0ef39..00bc9fad121 100644 --- a/scripts/bastion/pom.xml +++ b/scripts/bastion/pom.xml @@ -19,7 +19,7 @@ shared - + common 8.0-SNAPSHOT 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..0e270f1d349 --- /dev/null +++ b/scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/SignedRequest.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.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))|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, `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. + public static String canonical(String method, String target, byte[] bodySha256, + String nonce, long ts) { + return method + "|" + target + "|" + hex(bodySha256) + "|" + 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); + } +} From df4cfd3a1394dbc4ab21a1e36d0512e9b2a88806 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:51:55 +0300 Subject: [PATCH 06/53] Fix BigInteger division, modInverse and BigDecimal rounding; add EC P-256 key generation TDivision.divide normalized with a 64-bit leading-zero count applied to a 32-bit digit (and the overflow test in step D3 used the same), so division, mod and modInverse answered wrong values for ordinary 256-bit inputs. The Montgomery/Hars inverses are replaced by extended Euclid. bitCount and bitLength(int) had the same int-vs-long bug; BigDecimal.round always rounded away from zero and floor threw for negatives. KeyGenerator.ecP256() generates P-256 pairs through a new generateEcKeyPair port method (JavaSE, Android, iOS). The iOS port now loads EC keys in sign/verify (X9.62 DER signatures, as java.security returns) and generates them with SecKey; a PKCS#8 EC key without its public point is completed in Java (EcKeys) before signing, since SecKey cannot import one. Co-Authored-By: Claude Opus 5.5 --- .../impl/CodenameOneImplementation.java | 9 + CodenameOne/src/com/codename1/io/Util.java | 6 + .../src/com/codename1/security/EcKeys.java | 210 ++++++++++++++++++ .../com/codename1/security/KeyGenerator.java | 22 +- .../com/codename1/security/PrivateKey.java | 13 ++ .../src/com/codename1/security/Signature.java | 2 +- .../src/com/codename1/util/BigDecimal.java | 12 +- .../src/com/codename1/util/TBigDecimal.java | 2 +- .../src/com/codename1/util/TBigInteger.java | 2 +- .../src/com/codename1/util/TBitLevel.java | 8 +- .../src/com/codename1/util/TDivision.java | 190 +++------------- .../impl/android/AndroidImplementation.java | 15 ++ .../com/codename1/impl/javase/JavaSEPort.java | 15 ++ Ports/iOSPort/nativeSources/CN1Crypto.h | 6 + Ports/iOSPort/nativeSources/CN1Crypto.m | 172 +++++++++++++- Ports/iOSPort/nativeSources/IOSNative.m | 25 +++ .../codename1/impl/ios/IOSImplementation.java | 14 ++ .../src/com/codename1/impl/ios/IOSNative.java | 4 + .../builders/MacOSNativeBuilder.java | 1 + .../builders/MacOSNativeBuilderScanTest.java | 2 +- .../security/CipherSignatureTest.java | 64 ++++++ .../TestCodenameOneImplementation.java | 15 ++ .../util/BigIntegerModInverseTest.java | 112 ++++++++++ .../com/codename1/util/UtilBigNumberTest.java | 11 + 24 files changed, 752 insertions(+), 180 deletions(-) create mode 100644 CodenameOne/src/com/codename1/security/EcKeys.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/util/BigIntegerModInverseTest.java diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index f44ca682efe..3d1779431c9 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -13534,6 +13534,15 @@ public byte[][] generateRsaKeyPair(int bits) { throw cryptoUnsupported("generateRsaKeyPair"); } + /// Generates a fresh elliptic-curve key pair. `curve` is "P-256" + /// (secp256r1), the only curve defined. Returns `{publicKeyX509, + /// privateKeyPkcs8}`. The iOS port can only load a PKCS#8 EC key whose + /// ECPrivateKey carries the optional `publicKey` field, so a port should + /// emit it. + public byte[][] generateEcKeyPair(String curve) { + throw cryptoUnsupported("generateEcKeyPair"); + } + /// Generates `bytes` of fresh symmetric key material. The default just /// delegates to [#secureRandomBytes(byte[])] (no structure is required /// for AES keys). diff --git a/CodenameOne/src/com/codename1/io/Util.java b/CodenameOne/src/com/codename1/io/Util.java index 589c0844a95..7d2ebfef75e 100644 --- a/CodenameOne/src/com/codename1/io/Util.java +++ b/CodenameOne/src/com/codename1/io/Util.java @@ -1884,6 +1884,12 @@ public static byte[][] generateRsaKeyPair(int bits) { return cryptoImpl().generateRsaKeyPair(bits); } + /// Generates an EC key pair on the named curve (only "P-256" is + /// defined). Returns `{publicKeyX509, privateKeyPkcs8}`. + public static byte[][] generateEcKeyPair(String curve) { + return cryptoImpl().generateEcKeyPair(curve); + } + /// Generates `bytes` of fresh symmetric key material. public static byte[] generateSymmetricKey(int bytes) { return cryptoImpl().generateSymmetricKey(bytes); diff --git a/CodenameOne/src/com/codename1/security/EcKeys.java b/CodenameOne/src/com/codename1/security/EcKeys.java new file mode 100644 index 00000000000..a222c324de6 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/EcKeys.java @@ -0,0 +1,210 @@ +/* + * 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; + +/// 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 plain affine double-and-add. That is not constant +/// time, which is acceptable only because it runs once per key rather than +/// once per signature. +final class EcKeys { + private static final BigInteger P = new BigInteger( + "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff", 16); + private static final BigInteger A = P.subtract(BigInteger.valueOf(3)); + private static final BigInteger GX = new BigInteger( + "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296", 16); + private static final BigInteger GY = new BigInteger( + "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5", 16); + + /// SEQUENCE { OID id-ecPublicKey, OID prime256v1 } + private static final byte[] P256_ALG_ID = { + 0x30, 0x13, + 0x06, 0x07, 0x2a, (byte) 0x86, 0x48, (byte) 0xce, 0x3d, 0x02, 0x01, + 0x06, 0x08, 0x2a, (byte) 0x86, 0x48, (byte) 0xce, 0x3d, 0x03, 0x01, 0x07 + }; + + private EcKeys() {} + + /// Returns `pkcs8` itself when it already carries the point or is not a + /// P-256 key this parser understands, otherwise the same key re-encoded + /// with the point. + static byte[] withPublicPoint(byte[] pkcs8) { + byte[] d = privateScalarWithoutPoint(pkcs8); + if (d == null) { + return pkcs8; + } + BigInteger[] q = multiply(new BigInteger(1, d)); + if (q == null) { + return pkcs8; + } + byte[] out = new byte[138]; + int p = 0; + byte[] head = {0x30, (byte) 0x81, (byte) 0x87, 0x02, 0x01, 0x00}; + System.arraycopy(head, 0, out, p, head.length); + p += head.length; + System.arraycopy(P256_ALG_ID, 0, out, p, P256_ALG_ID.length); + p += P256_ALG_ID.length; + byte[] mid = {0x04, 0x6d, 0x30, 0x6b, 0x02, 0x01, 0x01, 0x04, 0x20}; + System.arraycopy(mid, 0, out, p, mid.length); + p += mid.length; + System.arraycopy(d, 0, out, p + 32 - d.length, d.length); + p += 32; + byte[] tail = {(byte) 0xa1, 0x44, 0x03, 0x42, 0x00, 0x04}; + System.arraycopy(tail, 0, out, p, tail.length); + p += tail.length; + putFixed(q[0], out, p); + putFixed(q[1], out, p + 32); + return out; + } + + 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); + } + + /// The private scalar of a P-256 PKCS#8 key that has no public point, or + /// null for anything else. + private static byte[] privateScalarWithoutPoint(byte[] b) { + int[] p = {0}; + if (enter(b, b.length, p, 0x30) < 0) { + return null; + } + int len = enter(b, b.length, p, 0x02); + if (len < 0) { + return null; + } + p[0] += len; + if (p[0] + P256_ALG_ID.length > b.length) { + return null; + } + for (int i = 0; i < P256_ALG_ID.length; i++) { + if (b[p[0] + i] != P256_ALG_ID[i]) { + return null; + } + } + p[0] += P256_ALG_ID.length; + if (enter(b, b.length, p, 0x04) < 0) { + return null; + } + len = enter(b, b.length, p, 0x30); + if (len < 0) { + return null; + } + int end = p[0] + len; + len = enter(b, end, p, 0x02); + if (len < 0) { + return null; + } + p[0] += len; + int dLen = enter(b, end, p, 0x04); + if (dLen < 1 || dLen > 32) { + return null; + } + byte[] d = new byte[dLen]; + System.arraycopy(b, p[0], d, 0, dLen); + p[0] += dLen; + if (p[0] < end && (b[p[0]] & 0xff) == 0xa0) { + len = enter(b, end, p, 0xa0); + if (len < 0) { + return null; + } + p[0] += len; + } + if (p[0] < end && (b[p[0]] & 0xff) == 0xa1) { + return null; + } + return d; + } + + /// Reads a tag and length at `p[0]`, leaving `p[0]` on the content. + /// Returns the content length, or -1 when the tag differs or the content + /// does not fit before `n`. + private static int enter(byte[] b, int n, int[] p, int tag) { + if (p[0] + 2 > n || (b[p[0]] & 0xff) != tag) { + return -1; + } + p[0]++; + int first = b[p[0]++] & 0xff; + int len; + if ((first & 0x80) == 0) { + len = first; + } else { + int sl = first & 0x7f; + if (sl < 1 || sl > 3 || p[0] + sl > n) { + return -1; + } + len = 0; + for (int i = 0; i < sl; i++) { + len = (len << 8) | (b[p[0]++] & 0xff); + } + } + if (p[0] + len > n) { + return -1; + } + return len; + } + + /// `k * G` in affine coordinates, or null for the point at infinity. + static BigInteger[] multiply(BigInteger k) { + BigInteger[] r = null; + BigInteger[] g = {GX, GY}; + for (int i = k.bitLength() - 1; i >= 0; i--) { + r = add(r, r); + if (k.testBit(i)) { + r = add(r, g); + } + } + return r; + } + + private static BigInteger[] add(BigInteger[] u, BigInteger[] v) { + if (u == null) { + return v; + } + if (v == null) { + return u; + } + BigInteger lambda; + if (u[0].equals(v[0])) { + if (!u[1].equals(v[1]) || u[1].signum() == 0) { + return null; + } + BigInteger num = u[0].multiply(u[0]).multiply(BigInteger.valueOf(3)).add(A); + lambda = num.multiply(u[1].shiftLeft(1).modInverse(P)).mod(P); + } else { + lambda = v[1].subtract(u[1]).multiply(v[0].subtract(u[0]).mod(P).modInverse(P)).mod(P); + } + BigInteger x = lambda.multiply(lambda).subtract(u[0]).subtract(v[0]).mod(P); + BigInteger y = lambda.multiply(u[0].subtract(x)).subtract(u[1]).mod(P); + return new BigInteger[]{x, y}; + } +} diff --git a/CodenameOne/src/com/codename1/security/KeyGenerator.java b/CodenameOne/src/com/codename1/security/KeyGenerator.java index 63cf94197d8..91f0a0c525c 100644 --- a/CodenameOne/src/com/codename1/security/KeyGenerator.java +++ b/CodenameOne/src/com/codename1/security/KeyGenerator.java @@ -25,7 +25,7 @@ import com.codename1.io.Util; /// Generates fresh cryptographic key material. AES keys are pulled directly -/// from [SecureRandom]; RSA key pairs are generated by the platform's native +/// from [SecureRandom]; RSA and EC key pairs are generated by the platform's native /// crypto provider. /// /// #### Example @@ -33,6 +33,7 @@ /// ```java /// SecretKey aes = KeyGenerator.aes(256); // AES-256 /// KeyPair rsa = KeyGenerator.rsa(2048); // RSA-2048 +/// KeyPair ec = KeyGenerator.ecP256(); // EC P-256, for ES256 /// ``` public final class KeyGenerator { private KeyGenerator() {} @@ -74,4 +75,23 @@ public static KeyPair rsa(int bits) { throw new CryptoException(re.getMessage(), re); } } + + /// Generates a fresh elliptic-curve key pair on NIST P-256 (secp256r1), + /// the curve of JWT's `ES256`. The public key is X.509 + /// SubjectPublicKeyInfo DER and the private key PKCS#8 DER, both with the + /// algorithm [PublicKey#EC], so the pair works directly with [Signature] + /// and [Signature#SHA256_WITH_ECDSA]. + public static KeyPair ecP256() { + try { + byte[][] pair = Util.generateEcKeyPair(EC_P256); + PublicKey pub = PublicKey.fromX509(PublicKey.EC, pair[0]); + PrivateKey priv = PrivateKey.fromPkcs8(PublicKey.EC, pair[1]); + return new KeyPair(pub, priv); + } catch (RuntimeException re) { + throw new CryptoException(re.getMessage(), re); + } + } + + /// The curve name [#ecP256()] hands the port. + static final String EC_P256 = "P-256"; } 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/Signature.java b/CodenameOne/src/com/codename1/security/Signature.java index 008213c1429..5d5c3c892af 100644 --- a/CodenameOne/src/com/codename1/security/Signature.java +++ b/CodenameOne/src/com/codename1/security/Signature.java @@ -56,7 +56,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/util/BigDecimal.java b/CodenameOne/src/com/codename1/util/BigDecimal.java index bc885d84908..031d2f7d511 100644 --- a/CodenameOne/src/com/codename1/util/BigDecimal.java +++ b/CodenameOne/src/com/codename1/util/BigDecimal.java @@ -120,8 +120,8 @@ 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; } @@ -134,9 +134,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 0e8fd939634..cb9a91ca7ee 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -18989,4 +18989,19 @@ public byte[][] generateRsaKeyPair(int bits) { throw new RuntimeException("RSA keypair generation failed: " + e.getMessage()); } } + + @Override + public byte[][] generateEcKeyPair(String curve) { + if (!"P-256".equals(curve)) { + throw new RuntimeException("unsupported EC curve: " + curve); + } + try { + java.security.KeyPairGenerator kpg = java.security.KeyPairGenerator.getInstance("EC"); + kpg.initialize(new java.security.spec.ECGenParameterSpec("secp256r1")); + java.security.KeyPair kp = kpg.generateKeyPair(); + return new byte[][]{ kp.getPublic().getEncoded(), kp.getPrivate().getEncoded() }; + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("EC keypair generation failed: " + e.getMessage()); + } + } } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 320e70034fc..24458244516 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -23962,4 +23962,19 @@ public byte[][] generateRsaKeyPair(int bits) { throw new RuntimeException("RSA keypair generation failed: " + e.getMessage()); } } + + @Override + public byte[][] generateEcKeyPair(String curve) { + if (!"P-256".equals(curve)) { + throw new RuntimeException("unsupported EC curve: " + curve); + } + try { + java.security.KeyPairGenerator kpg = java.security.KeyPairGenerator.getInstance("EC"); + kpg.initialize(new java.security.spec.ECGenParameterSpec("secp256r1")); + java.security.KeyPair kp = kpg.generateKeyPair(); + return new byte[][]{ kp.getPublic().getEncoded(), kp.getPrivate().getEncoded() }; + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("EC keypair generation failed: " + e.getMessage()); + } + } } diff --git a/Ports/iOSPort/nativeSources/CN1Crypto.h b/Ports/iOSPort/nativeSources/CN1Crypto.h index e7e99bb8211..76022e490f8 100644 --- a/Ports/iOSPort/nativeSources/CN1Crypto.h +++ b/Ports/iOSPort/nativeSources/CN1Crypto.h @@ -152,4 +152,10 @@ int cn1_crypto_generate_rsa_keypair(int bits, uint8_t* outPub, int pubCap, int* pubLen, uint8_t* outPriv, int privCap, int* privLen); +/// Generates an EC P-256 key pair: X.509 SubjectPublicKeyInfo (91 bytes) into +/// `outPub` and PKCS#8 (138 bytes, ECPrivateKey with its public point) into +/// `outPriv`. Returns 0 on success, negative on error. +int cn1_crypto_generate_ec_keypair(uint8_t* outPub, int pubCap, int* pubLen, + uint8_t* outPriv, int privCap, int* privLen); + #endif /* CN1Crypto_h */ diff --git a/Ports/iOSPort/nativeSources/CN1Crypto.m b/Ports/iOSPort/nativeSources/CN1Crypto.m index c8c86d9f225..144f16580d1 100644 --- a/Ports/iOSPort/nativeSources/CN1Crypto.m +++ b/Ports/iOSPort/nativeSources/CN1Crypto.m @@ -438,6 +438,106 @@ int cn1_crypto_rsa_decrypt(int paddingKind, return rc; } +/* --- EC P-256 ---------------------------------------------------------- */ + +/* The AlgorithmIdentifier every P-256 key carries: + * SEQUENCE { OID id-ecPublicKey, OID prime256v1 }. */ +static const uint8_t CN1_EC_P256_ALG_ID[] = { + 0x30, 0x13, + 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, + 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07 +}; + +/* Reads a DER tag and length at *p, checks the tag and that the content fits + * in `n`, and leaves *p at the first content byte. Returns the content length + * or -1. */ +static int cn1_der_enter(const uint8_t* b, int n, int* p, uint8_t tag) { + if (*p + 2 > n || b[*p] != tag) return -1; + (*p)++; + int len; + uint8_t first = b[(*p)++]; + if ((first & 0x80) == 0) { + len = first; + } else { + int sl = first & 0x7f; + if (sl < 1 || sl > 3 || *p + sl > n) return -1; + len = 0; + for (int i = 0; i < sl; i++) len = (len << 8) | b[(*p)++]; + } + if (len < 0 || *p + len > n) return -1; + return len; +} + +/* Checks the AlgorithmIdentifier at *p names P-256 and steps over it. */ +static int cn1_der_skip_p256_alg(const uint8_t* b, int n, int* p) { + int len = (int) sizeof(CN1_EC_P256_ALG_ID); + if (*p + len > n || memcmp(b + *p, CN1_EC_P256_ALG_ID, len) != 0) return -1; + *p += len; + return 0; +} + +static SecKeyRef cn1_ec_create(NSData* keyData, CFStringRef keyClass) { + NSDictionary* attrs = @{ + (id) kSecAttrKeyType: (id) kSecAttrKeyTypeECSECPrimeRandom, + (id) kSecAttrKeyClass: (__bridge id) keyClass, + (id) kSecAttrKeySizeInBits: @256 + }; + CFErrorRef error = NULL; + SecKeyRef key = SecKeyCreateWithData((__bridge CFDataRef) keyData, + (__bridge CFDictionaryRef) attrs, + &error); + if (error) CFRelease(error); + return key; +} + +/* X.509 SubjectPublicKeyInfo -> SecKey. SecKey takes the bare X9.63 point + * (0x04 || X || Y), which is the BIT STRING's content. */ +static SecKeyRef cn1_load_ec_public(const uint8_t* x509, int x509Len) { + int p = 0; + if (cn1_der_enter(x509, x509Len, &p, 0x30) < 0) return NULL; + if (cn1_der_skip_p256_alg(x509, x509Len, &p) != 0) return NULL; + int bitLen = cn1_der_enter(x509, x509Len, &p, 0x03); + if (bitLen != 66 || x509[p] != 0x00 || x509[p + 1] != 0x04) return NULL; + return cn1_ec_create([NSData dataWithBytes:(x509 + p + 1) length:65], + kSecAttrKeyClassPublic); +} + +/* PKCS#8 -> SecKey. SecKey wants 0x04 || X || Y || D, so the ECPrivateKey + * (RFC 5915) must carry its optional [1] publicKey: SecKey cannot derive the + * point from D. Keys this port and the Android port generate carry it; a + * PKCS#8 without it is reported as a bad key rather than guessed at. */ +static SecKeyRef cn1_load_ec_private(const uint8_t* pkcs8, int pkcs8Len) { + int p = 0, len; + if (cn1_der_enter(pkcs8, pkcs8Len, &p, 0x30) < 0) return NULL; + if ((len = cn1_der_enter(pkcs8, pkcs8Len, &p, 0x02)) < 0) return NULL; + p += len; + if (cn1_der_skip_p256_alg(pkcs8, pkcs8Len, &p) != 0) return NULL; + if (cn1_der_enter(pkcs8, pkcs8Len, &p, 0x04) < 0) return NULL; + int ecEnd; + if ((len = cn1_der_enter(pkcs8, pkcs8Len, &p, 0x30)) < 0) return NULL; + ecEnd = p + len; + if ((len = cn1_der_enter(pkcs8, ecEnd, &p, 0x02)) < 0) return NULL; + p += len; + int dLen = cn1_der_enter(pkcs8, ecEnd, &p, 0x04); + if (dLen < 1 || dLen > 32) return NULL; + uint8_t raw[97]; + memset(raw, 0, sizeof(raw)); + memcpy(raw + 65 + (32 - dLen), pkcs8 + p, dLen); + p += dLen; + if (p < ecEnd && pkcs8[p] == 0xa0) { + if ((len = cn1_der_enter(pkcs8, ecEnd, &p, 0xa0)) < 0) return NULL; + p += len; + } + if (cn1_der_enter(pkcs8, ecEnd, &p, 0xa1) < 0) return NULL; + int bitLen = cn1_der_enter(pkcs8, ecEnd, &p, 0x03); + if (bitLen != 66 || pkcs8[p] != 0x00 || pkcs8[p + 1] != 0x04) return NULL; + memcpy(raw, pkcs8 + p + 1, 65); + SecKeyRef key = cn1_ec_create([NSData dataWithBytes:raw length:sizeof(raw)], + kSecAttrKeyClassPrivate); + memset(raw, 0, sizeof(raw)); + return key; +} + static SecKeyAlgorithm signature_alg(int algorithm) { switch (algorithm) { case 0: return kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA256; @@ -456,9 +556,12 @@ int cn1_crypto_sign(int algorithm, uint8_t* out, int outCap) { SecKeyAlgorithm alg = signature_alg(algorithm); if (!alg) return CN1_CRYPTO_E_UNSUPPORTED; - // ECDSA keys go through a separate loader; here we assume RSA. ECDSA - // support would parse the PKCS#8 EC body and pass kSecAttrKeyTypeECSECPrimeRandom. - SecKeyRef key = cn1_load_rsa_private(pkcs8, pkcs8Len); + // Codes 3-5 are ECDSA. The X9.62 algorithms produce (and verify) the DER + // SEQUENCE { r, s } that java.security's SHAxxxwithECDSA returns on the + // JavaSE and Android ports; Jwt converts to the JOSE r||s form itself. + SecKeyRef key = algorithm >= 3 + ? cn1_load_ec_private(pkcs8, pkcs8Len) + : cn1_load_rsa_private(pkcs8, pkcs8Len); if (!key) return CN1_CRYPTO_E_BAD_KEY; int rc = cn1_seckey_op(key, alg, 2, data, dataLen, out, outCap); CFRelease(key); @@ -471,7 +574,9 @@ int cn1_crypto_verify(int algorithm, const uint8_t* sig, int sigLen) { SecKeyAlgorithm alg = signature_alg(algorithm); if (!alg) return CN1_CRYPTO_E_UNSUPPORTED; - SecKeyRef key = cn1_load_rsa_public(x509, x509Len); + SecKeyRef key = algorithm >= 3 + ? cn1_load_ec_public(x509, x509Len) + : cn1_load_rsa_public(x509, x509Len); if (!key) return CN1_CRYPTO_E_BAD_KEY; CFErrorRef error = NULL; NSData* dataObj = [NSData dataWithBytes:data length:dataLen]; @@ -485,6 +590,58 @@ int cn1_crypto_verify(int algorithm, return ok ? 1 : 0; } +int cn1_crypto_generate_ec_keypair(uint8_t* outPub, int pubCap, int* pubLen, + uint8_t* outPriv, int privCap, int* privLen) { + if (!pubLen || !privLen) return CN1_CRYPTO_E_BAD_INPUT; + *pubLen = 0; + *privLen = 0; + if (pubCap < 91 || privCap < 138) return CN1_CRYPTO_E_BAD_INPUT; + NSDictionary* attrs = @{ + (id) kSecAttrKeyType: (id) kSecAttrKeyTypeECSECPrimeRandom, + (id) kSecAttrKeySizeInBits: @256, + (id) kSecPrivateKeyAttrs: @{ (id) kSecAttrIsPermanent: @NO } + }; + CFErrorRef error = NULL; + SecKeyRef priv = SecKeyCreateRandomKey((__bridge CFDictionaryRef) attrs, &error); + if (!priv || error) { + if (error) CFRelease(error); + if (priv) CFRelease(priv); + return CN1_CRYPTO_E_GENERIC; + } + /* 0x04 || X || Y || D, 97 bytes for P-256. */ + NSData* raw = (__bridge_transfer NSData*) SecKeyCopyExternalRepresentation(priv, &error); + if (error) { CFRelease(error); error = NULL; } + CFRelease(priv); + if (!raw || [raw length] != 97) return CN1_CRYPTO_E_GENERIC; + const uint8_t* r = (const uint8_t*) [raw bytes]; + + /* SubjectPublicKeyInfo: SEQUENCE(89) { algId, BIT STRING(66) { 0, point } } */ + uint8_t* p = outPub; + *p++ = 0x30; *p++ = 0x59; + memcpy(p, CN1_EC_P256_ALG_ID, sizeof(CN1_EC_P256_ALG_ID)); p += sizeof(CN1_EC_P256_ALG_ID); + *p++ = 0x03; *p++ = 0x42; *p++ = 0x00; + memcpy(p, r, 65); + *pubLen = 91; + + /* PKCS#8: SEQUENCE(135) { INT 0, algId, OCTET STRING(109) { + * ECPrivateKey SEQUENCE(107) { INT 1, OCTET STRING(32) D, + * [1] { BIT STRING(66) { 0, point } } } } } */ + uint8_t* q = outPriv; + *q++ = 0x30; *q++ = 0x81; *q++ = 0x87; + *q++ = 0x02; *q++ = 0x01; *q++ = 0x00; + memcpy(q, CN1_EC_P256_ALG_ID, sizeof(CN1_EC_P256_ALG_ID)); q += sizeof(CN1_EC_P256_ALG_ID); + *q++ = 0x04; *q++ = 0x6d; + *q++ = 0x30; *q++ = 0x6b; + *q++ = 0x02; *q++ = 0x01; *q++ = 0x01; + *q++ = 0x04; *q++ = 0x20; + memcpy(q, r + 65, 32); q += 32; + *q++ = 0xa1; *q++ = 0x44; + *q++ = 0x03; *q++ = 0x42; *q++ = 0x00; + memcpy(q, r, 65); + *privLen = 138; + return 0; +} + /* --- RSA key-pair generation ------------------------------------------ */ int cn1_crypto_generate_rsa_keypair(int bits, @@ -630,5 +787,12 @@ int cn1_crypto_generate_rsa_keypair(int bits, uint8_t* outPub, int pubCap, int* if (privLen) *privLen = 0; return CN1_CRYPTO_E_UNSUPPORTED; } +int cn1_crypto_generate_ec_keypair(uint8_t* outPub, int pubCap, int* pubLen, + uint8_t* outPriv, int privCap, int* privLen) { + (void) outPub; (void) pubCap; (void) outPriv; (void) privCap; + if (pubLen) *pubLen = 0; + if (privLen) *privLen = 0; + return CN1_CRYPTO_E_UNSUPPORTED; +} #endif /* CN1_INCLUDE_CRYPTO */ diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index eec3a156203..0545c157fa7 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -19009,6 +19009,17 @@ JAVA_INT com_codename1_impl_ios_IOSNative_generateRsaKeyPair___int_byte_1ARRAY_b return rc; } +JAVA_INT com_codename1_impl_ios_IOSNative_generateEcKeyPair___byte_1ARRAY_byte_1ARRAY_int_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT outPub, JAVA_OBJECT outPriv, JAVA_OBJECT lengths) { + int pubLen = 0, privLen = 0; + int rc = cn1_crypto_generate_ec_keypair( + (uint8_t*) CN1_PRIM_ARR_DATA(outPub), (int) CN1_PRIM_ARR_LEN(outPub), &pubLen, + (uint8_t*) CN1_PRIM_ARR_DATA(outPriv), (int) CN1_PRIM_ARR_LEN(outPriv), &privLen); + JAVA_ARRAY_INT* lens = (JAVA_ARRAY_INT*) CN1_PRIM_ARR_DATA(lengths); + lens[0] = pubLen; + lens[1] = privLen; + return rc; +} + #else /* CN1_INCLUDE_CRYPTO */ /* @@ -19068,6 +19079,16 @@ JAVA_INT com_codename1_impl_ios_IOSNative_generateRsaKeyPair___int_byte_1ARRAY_b return CN1_CRYPTO_E_UNSUPPORTED; } +JAVA_INT com_codename1_impl_ios_IOSNative_generateEcKeyPair___byte_1ARRAY_byte_1ARRAY_int_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT outPub, JAVA_OBJECT outPriv, JAVA_OBJECT lengths) { + (void) instanceObject; (void) outPub; (void) outPriv; + if (lengths != JAVA_NULL) { + JAVA_ARRAY_INT* lens = (JAVA_ARRAY_INT*) CN1_PRIM_ARR_DATA(lengths); + lens[0] = 0; + lens[1] = 0; + } + return CN1_CRYPTO_E_UNSUPPORTED; +} + #endif /* CN1_INCLUDE_CRYPTO */ // ============================================================================ @@ -23090,6 +23111,10 @@ JAVA_INT com_codename1_impl_ios_IOSNative_generateRsaKeyPair___int_byte_1ARRAY_b return com_codename1_impl_ios_IOSNative_generateRsaKeyPair___int_byte_1ARRAY_byte_1ARRAY_int_1ARRAY(CN1_THREAD_STATE_PASS_ARG instanceObject, bits, outPub, outPriv, lengths); } +JAVA_INT com_codename1_impl_ios_IOSNative_generateEcKeyPair___byte_1ARRAY_byte_1ARRAY_int_1ARRAY_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT outPub, JAVA_OBJECT outPriv, JAVA_OBJECT lengths) { + return com_codename1_impl_ios_IOSNative_generateEcKeyPair___byte_1ARRAY_byte_1ARRAY_int_1ARRAY(CN1_THREAD_STATE_PASS_ARG instanceObject, outPub, outPriv, lengths); +} + JAVA_LONG com_codename1_impl_ios_IOSNative_createWebSocketNative___int_java_lang_String_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT connectionId, JAVA_OBJECT url) { return com_codename1_impl_ios_IOSNative_createWebSocketNative___int_java_lang_String(CN1_THREAD_STATE_PASS_ARG instanceObject, connectionId, url); } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 8a65983a136..aec6221f09c 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -16334,4 +16334,18 @@ public byte[][] generateRsaKeyPair(int bits) { if (rc < 0) throw new RuntimeException("RSA keypair generation failed: code " + rc); return new byte[][]{ cryptoTrim(pubBuf, lens[0]), cryptoTrim(privBuf, lens[1]) }; } + + @Override + public byte[][] generateEcKeyPair(String curve) { + if (!"P-256".equals(curve)) { + throw new RuntimeException("unsupported EC curve: " + curve); + } + // P-256 encodes to exactly 91 (X.509) and 138 (PKCS#8) bytes. + byte[] pubBuf = new byte[128]; + byte[] privBuf = new byte[192]; + int[] lens = new int[2]; + int rc = nativeInstance.generateEcKeyPair(pubBuf, privBuf, lens); + if (rc < 0) throw new RuntimeException("EC keypair generation failed: code " + rc); + return new byte[][]{ cryptoTrim(pubBuf, lens[0]), cryptoTrim(privBuf, lens[1]) }; + } } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index e19bb335f74..88bc52a2152 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -2274,6 +2274,10 @@ native int aesGcm(int encrypt, byte[] key, byte[] iv, /// private-key DER length. Returns 0 on success, negative on error. native int generateRsaKeyPair(int bits, byte[] outPub, byte[] outPriv, int[] lengths); + /// EC P-256 counterpart of [#generateRsaKeyPair]; same `lengths` and + /// return contract. + native int generateEcKeyPair(byte[] outPub, byte[] outPriv, int[] lengths); + // --- Nearby devices (Nearby Interaction, MultipeerConnectivity, ---------- // AccessorySetupKit) ------------------------------------------------ diff --git a/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java index f028f016b1b..428c6a8ea5a 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java @@ -1818,6 +1818,7 @@ static boolean usesUtilCrypto(String cls, String method) { || method.equals("cryptoSign") || method.equals("cryptoVerify") || method.equals("generateRsaKeyPair") + || method.equals("generateEcKeyPair") || method.equals("generateSymmetricKey"); } diff --git a/maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderScanTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderScanTest.java index 1cff890e7d1..dda65f73c81 100644 --- a/maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderScanTest.java +++ b/maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderScanTest.java @@ -493,7 +493,7 @@ public void theFrameworksOwnPermissionRequestStillDoesNotCount() { public void theUtilCryptoDelegatesCount() { String[] crypto = { "secureRandomBytes", "aesEncrypt", "aesDecrypt", "rsaEncrypt", "rsaDecrypt", - "cryptoSign", "cryptoVerify", "generateRsaKeyPair", "generateSymmetricKey" + "cryptoSign", "cryptoVerify", "generateRsaKeyPair", "generateEcKeyPair", "generateSymmetricKey" }; for (String m : crypto) { assertTrue("Util." + m + " reaches the crypto natives and must enable them", 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..1a82a189b43 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 @@ -76,6 +76,70 @@ void rsaSignAndVerify() { assertFalse(Signature.verify(Signature.SHA256_WITH_RSA, kp.getPublicKey(), data, sig)); } + @Test + void ecP256SignAndVerify() { + KeyPair kp = KeyGenerator.ecP256(); + 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.ecP256(); + 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.ecP256(); + 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.ecP256().getPublicKey())); + } + @Test void jwtRsRoundTrip() { KeyPair kp = KeyGenerator.rsa(2048); diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java index 8d884d0ce5c..404822cd776 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java @@ -4837,6 +4837,21 @@ public byte[][] generateRsaKeyPair(int bits) { } } + @Override + public byte[][] generateEcKeyPair(String curve) { + if (!"P-256".equals(curve)) { + throw new RuntimeException("unsupported EC curve: " + curve); + } + try { + java.security.KeyPairGenerator kpg = java.security.KeyPairGenerator.getInstance("EC"); + kpg.initialize(new java.security.spec.ECGenParameterSpec("secp256r1")); + java.security.KeyPair kp = kpg.generateKeyPair(); + return new byte[][]{ kp.getPublic().getEncoded(), kp.getPrivate().getEncoded() }; + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("EC keypair generation failed: " + e.getMessage()); + } + } + public static final class TestDatabase extends Database { private final String name; private boolean inTransaction; 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..fb0fdf09cb5 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 @@ -107,4 +107,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()); + } } From 65dc3e878afce6e6a6b043b8a82889f84dbb44f1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:55:30 +0300 Subject: [PATCH 07/53] Bastion sample: every screen -- onboarding, dashboard, accounts, transfers, payees, cards, exchange, crypto withdrawals, settings, admin Onboarding registers with the server's password rule checked as typed, signs in keeping the server session for a passkey ceremony, enrols TOTP with a QR code (own byte-mode level-M encoder, checked against segno for versions 1-10 and every mask) and recovery codes, offers biometric unlock as a choice and registers the device key. Money screens sign with RequestSigner, reuse one idempotency key per payment across retries, and turn a missing-signature refusal into an offer to register the device. Amounts are formatted and parsed with integer math only (money/Amounts). Bundle_en.properties lists every key; BundleTest keeps it complete. Co-Authored-By: Claude Opus 5.5 --- scripts/bastion/common/src/main/css/theme.css | 306 ++ .../codenameone/examples/bastion/Bastion.java | 14 +- .../codenameone/examples/bastion/Prefs.java | 54 + .../examples/bastion/money/Amounts.java | 179 + .../examples/bastion/money/Assets.java | 94 + .../examples/bastion/net/Session.java | 119 +- .../examples/bastion/ui/AccountForm.java | 156 + .../examples/bastion/ui/AdminForm.java | 87 + .../examples/bastion/ui/CardsForm.java | 126 + .../examples/bastion/ui/DeviceSetup.java | 112 + .../examples/bastion/ui/ExchangeForm.java | 84 + .../examples/bastion/ui/HomeForm.java | 217 +- .../codenameone/examples/bastion/ui/Lang.java | 14 +- .../examples/bastion/ui/LockForm.java | 25 +- .../examples/bastion/ui/MarketForm.java | 318 ++ .../codenameone/examples/bastion/ui/Nav.java | 99 + .../examples/bastion/ui/OnboardingForm.java | 383 ++ .../examples/bastion/ui/PayeesForm.java | 126 + .../examples/bastion/ui/QrCode.java | 510 +++ .../examples/bastion/ui/QrView.java | 63 + .../examples/bastion/ui/ReceiptForm.java | 51 + .../examples/bastion/ui/SettingsForm.java | 77 + .../examples/bastion/ui/SignInForm.java | 3 + .../examples/bastion/ui/TransferForm.java | 218 + .../codenameone/examples/bastion/ui/Ui.java | 160 + .../examples/bastion/ui/WithdrawForm.java | 244 ++ .../common/src/main/l10n/Bundle_en.properties | 314 ++ .../examples/bastion/money/AmountsTest.java | 73 + .../examples/bastion/ui/BundleTest.java | 74 + .../examples/bastion/ui/QrCodeTest.java | 113 + .../common/src/test/resources/qr-vectors.txt | 3600 +++++++++++++++++ 31 files changed, 7950 insertions(+), 63 deletions(-) create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/Prefs.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/money/Amounts.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/money/Assets.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/AccountForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/AdminForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/CardsForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/DeviceSetup.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/ExchangeForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/MarketForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Nav.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/OnboardingForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/PayeesForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/QrCode.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/QrView.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/ReceiptForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SettingsForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/TransferForm.java create mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/WithdrawForm.java create mode 100644 scripts/bastion/common/src/main/l10n/Bundle_en.properties create mode 100644 scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/money/AmountsTest.java create mode 100644 scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/BundleTest.java create mode 100644 scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/QrCodeTest.java create mode 100644 scripts/bastion/common/src/test/resources/qr-vectors.txt diff --git a/scripts/bastion/common/src/main/css/theme.css b/scripts/bastion/common/src/main/css/theme.css index 29e39afcaca..56b86304636 100644 --- a/scripts/bastion/common/src/main/css/theme.css +++ b/scripts/bastion/common/src/main/css/theme.css @@ -235,6 +235,282 @@ BnField { 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; +} + +BnHeroAmount { + color: var(--brand-contrast); + font-family: "native:MainBold"; + font-size: 6.4mm; + padding: 0.8mm 0 0.8mm 0; + margin: 0; +} + +BnHeroNote { + color: var(--brand-contrast); + font-family: "native:MainLight"; + font-size: 2.3mm; + padding: 0; + margin: 0; +} + +BnRowValue { + color: var(--text); + font-family: "native:MainBold"; + font-size: 2.8mm; + text-align: right; + padding: 0 0 0 2mm; + margin: 0; +} + +BnRowValueMuted { + color: var(--text-muted); + font-family: "native:MainBold"; + font-size: 2.8mm; + text-align: right; + padding: 0 0 0 2mm; + margin: 0; +} + +BnRowValueMono { + color: var(--text); + font-family: "native:MainRegular"; + font-size: 2.2mm; + text-align: right; + padding: 0 0 0 2mm; + margin: 0; +} + +BnAmountIn { + color: var(--success); + font-family: "native:MainBold"; + font-size: 2.8mm; + text-align: right; + padding: 0 0 0 2mm; + margin: 0; +} + +BnAmountOut { + color: var(--text); + font-family: "native:MainBold"; + font-size: 2.8mm; + text-align: right; + padding: 0 0 0 2mm; + margin: 0; +} + +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; +} + +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; +} + +BnBannerText { + color: var(--text); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0.5mm 0 0.5mm 0; + margin: 0; +} + +/* ------------------------------------------------------------- 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 */ + +BnCard { + background-color: var(--brand); + border-radius: 3mm; + padding: 4mm 4mm 4mm 4mm; + margin: 2mm 0 1mm 0; +} + +BnCardFrozen { + background-color: var(--text-muted); + border-radius: 3mm; + padding: 4mm 4mm 4mm 4mm; + margin: 2mm 0 1mm 0; +} + +BnCardBrand { + color: var(--brand-contrast); + font-family: "native:MainBold"; + font-size: 3mm; + padding: 0 0 3mm 0; + margin: 0; +} + +BnCardNumber { + color: var(--brand-contrast); + font-family: "native:MainRegular"; + font-size: 3.6mm; + padding: 0 0 1mm 0; + margin: 0; +} + +BnCardNote { + color: var(--brand-contrast); + font-family: "native:MainLight"; + font-size: 2.4mm; + padding: 0; + margin: 0; +} + +/* ------------------------------------------------------------- exchange */ + +BnBookRow { + padding: 0.5mm 1mm 0.5mm 1mm; + margin: 0; +} + +BnAsk { + color: var(--danger); + font-family: "native:MainBold"; + font-size: 2.6mm; + padding: 0; + margin: 0; +} + +BnBid { + color: var(--success); + font-family: "native:MainBold"; + font-size: 2.6mm; + padding: 0; + margin: 0; +} + +BnBookQty { + color: var(--text); + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: right; + padding: 0; + margin: 0; +} + +/* ----------------------------------------------------------- onboarding */ + +BnRule { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.4mm; + padding: 0.3mm 0 0.3mm 0; + margin: 0; +} + +BnRuleMet { + color: var(--success); + font-family: "native:MainRegular"; + font-size: 2.4mm; + padding: 0.3mm 0 0.3mm 0; + margin: 0; +} + +BnSecret { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.2mm; + text-align: center; + padding: 1mm 0 1mm 0; + margin: 0; +} + +/* 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; +} + /* --------------------------------------------------------------- dark mode */ @media (prefers-color-scheme: dark) { @@ -257,4 +533,34 @@ BnField { 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); } + BnCard { background-color: var(--brand-dark); } + BnCardFrozen { background-color: var(--surface-press-dark); } + BnCardBrand { color: var(--brand-contrast-dark); } + BnCardNumber { color: var(--brand-contrast-dark); } + BnCardNote { color: var(--brand-contrast-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); } } 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 index e49baf7b162..f7e7b32c47c 100644 --- 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 @@ -24,9 +24,9 @@ import com.codenameone.examples.bastion.net.Session; import com.codenameone.examples.bastion.security.Lock; import com.codenameone.examples.bastion.security.Protect; -import com.codenameone.examples.bastion.ui.HomeForm; 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; @@ -50,6 +50,7 @@ public void runApp() { Protect.install(); Session.start(AppConfig.serverUrl()); Session.onSignedOut(Bastion::signInScreen); + Nav.install(Bastion::signInScreen); Lock.install(new Lock.Screens() { @Override public void showLocked() { @@ -67,12 +68,9 @@ public void showUnlocked(Form previous) { }); Session.restore(() -> { // A stored session opens behind the lock: whoever holds the phone - // proves they are its owner first. - if (Lock.biometricsAvailable()) { - Lock.lock(); - } else { - home(); - } + // proves they are its owner first -- with a biometric if they + // chose one, else with the password (LockForm). + Lock.lock(); }, Bastion::signInScreen, Bastion::signInScreen); } @@ -98,6 +96,6 @@ static void signInScreen() { } static void home() { - HomeForm.show(Bastion::signInScreen); + 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..645d14aabc7 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/Prefs.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; + +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 BIOMETRIC = "bastion.biometricUnlock"; + private static final String ONBOARDED = "bastion.onboarded."; + + private Prefs() { + } + + /// Whether the user chose to unlock with a fingerprint or face. Off, the + /// lock asks for the password again: a stronger default than a + /// biometric nobody agreed to. + public static boolean biometricUnlock() { + return Preferences.get(BIOMETRIC, false); + } + + public static void setBiometricUnlock(boolean on) { + Preferences.set(BIOMETRIC, on); + } + + /// 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..d536bda19c3 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/money/Amounts.java @@ -0,0 +1,179 @@ +/* + * 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; + +/// 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. +public final class Amounts { + /// What [#parse] answers for text that is not an amount. + public static final long INVALID = -1; + + private Amounts() { + } + + /// `units` with `decimals` places and thousands separators: + /// `format(123456, 2)` is `1,234.56`. 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 && (whole.length() - iter) % 3 == 0) { + out.append(','); + } + out.append(whole.charAt(iter)); + } + if (fraction.length() > 0) { + out.append('.').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 separators. + public static String plain(long units, String asset) { + int decimals = Assets.decimals(asset); + String text = format(units, decimals, Assets.isCrypto(asset) ? 0 : decimals); + return text.replace(",", ""); + } + + /// What the user typed, in units of an asset with `decimals` places, or + /// [#INVALID]. Accepts `12`, `12.5`, `1,200.50`, `.5`; refuses a sign, + /// a second point, 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; + for (int iter = 0; iter < trimmed.length(); iter++) { + char c = trimmed.charAt(iter); + if (c == ',' && places < 0) { + continue; + } + if (c == '.') { + if (places >= 0) { + return INVALID; + } + places = 0; + continue; + } + if (c < '0' || c > '9') { + return INVALID; + } + if (places >= 0) { + places++; + if (places > decimals) { + return INVALID; + } + } + anyDigit = true; + value = times10plus(value, c - '0'); + if (value < 0) { + return INVALID; + } + } + if (!anyDigit) { + return INVALID; + } + for (int pad = Math.max(places, 0); pad < decimals; pad++) { + value = times10plus(value, 0); + if (value < 0) { + return INVALID; + } + } + return value; + } + + /// [#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..c845933decc --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/money/Assets.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.money; + +/// 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; this mirrors its rows +/// (`V2__ledger.sql`) because the API has no call that lists them, and an +/// asset missing here is 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 final Asset[] ALL = { + new Asset("USD", "US Dollar", FIAT, 2), + new Asset("EUR", "Euro", FIAT, 2), + new Asset("BTC", "Bitcoin", CRYPTO, 8), + // 9, not the chain's 18: see the comment beside the row on the server. + new Asset("ETH", "Ether", CRYPTO, 9), + }; + + /// 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() { + } + + /// 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/Session.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Session.java index ca3409770da..f7dff5dd00c 100644 --- 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 @@ -31,6 +31,7 @@ import com.codename1.io.oidc.SecureStorageTokenStore; 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; @@ -74,6 +75,10 @@ public final class Session { /// 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() { } @@ -363,10 +368,15 @@ private static void authorize(final String server, final String cookie, final Ru return; } // The server session has done its work; it is ended rather - // than left to run out. - Step end = new Step("POST", server + "/logout"); - end.addRequestHeader("Cookie", cookie); - end.send(() -> { }); + // 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; + ceremonyCookie = cookie; + } else { + endServerSession(server, cookie); + } session++; authorizer.setTokens(tokens); store.save(storeKey(base), tokens); @@ -375,9 +385,110 @@ private static void authorize(final String server, final String cookie, final Ru }); } + /// 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.addRequestHeader("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.addRequestHeader("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() { + 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.addRequestHeader("Cookie", cookie); + end.send(() -> { }); + } + + /// `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()); 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..ed3c071f288 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/AccountForm.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.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.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.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(); + final Runnable again = () -> 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"); + card.addActionListener(evt -> Api.banking().issueCard(account.id(), Net.to( + (CardDto issued) -> CardsForm.show(again), + (status, message) -> Dialog.show(Lang.tr("No card"), Lang.tr(message), + Lang.tr("OK"), null)))); + content.add(card); + } + + content.add(Ui.section("History")); + if (postings.isEmpty()) { + content.add(Ui.empty("Nothing has moved in this account yet.")); + } + for (PostingDto posting : postings) { + boolean in = posting.amount() > 0; + String amount = (in ? "+" : "") + Amounts.format(posting.amount(), posting.asset()); + content.add(Ui.row(Lang.tr(in ? "Money in" : "Money out"), Ui.when(posting.createdAt()), + amount, in ? "BnAmountIn" : "BnAmountOut", null)); + } + content.revalidate(); + } + + /// 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"), Lang.tr("Enter an amount, like 25.00."), Lang.tr("OK"), null); + return; + } + // One key for this action, kept across the retry after registering. + final String key = RequestSigner.idempotencyKey(); + final CashDto cash = new CashDto(units, deposit ? "Deposit" : "Cash withdrawal"); + DeviceSetup.withDevice(failed -> { + Net.Ok ok = receipt -> 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, cash), + cash, Net.to(ok, failed)); + } + }, (status, message) -> Dialog.show(Lang.tr("Not done"), Lang.tr(message), + Lang.tr("OK"), null)); + } +} diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/AdminForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/AdminForm.java new file mode 100644 index 00000000000..b788231f6e2 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/AdminForm.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.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.bastion.api.VerificationDto; +import com.codenameone.examples.bastion.net.Api; +import com.codenameone.examples.bastion.net.Net; +import com.codenameone.examples.bastion.net.Session; + +/// The two checks an operator runs: that the audit log's hash chain is +/// unbroken, and that every ledger entry balances and every balance is the +/// sum of its postings. +/// +/// Each is recomputed by the server from the rows themselves, not read from a +/// flag, so a row edited behind the application's back shows up here as a +/// named problem. Reached only from the menu of an account with the admin +/// role, and refused by the server to anyone else. +public final class AdminForm { + private AdminForm() { + } + + /// `back` is null when this is the admin's home screen. + public static void show(Runnable back) { + Form form = Nav.form("Administration", back); + if (Session.user() == null || !Session.user().admin()) { + form.add(Ui.empty("Administrators only.")); + Ui.show(form, false); + return; + } + form.add(Ui.muted(Lang.tr("Recomputed from the stored rows each time you ask."))); + final Container audit = new Container(BoxLayout.y()); + final Container ledger = new Container(BoxLayout.y()); + Button verifyAudit = Ui.primary("Verify the audit log"); + verifyAudit.addActionListener(evt -> { + Ui.loading(audit); + Api.admin().verifyAudit(Net.to((VerificationDto v) -> result(audit, v, + "The audit chain is intact."), + (status, message) -> Ui.failed(audit, message, () -> { }))); + }); + Button verifyLedger = Ui.primary("Verify the ledger"); + verifyLedger.addActionListener(evt -> { + Ui.loading(ledger); + Api.admin().verifyLedger(Net.to((VerificationDto v) -> result(ledger, v, + "Every entry balances and every balance matches its postings."), + (status, message) -> Ui.failed(ledger, message, () -> { }))); + }); + form.add(Ui.section("Audit log")); + form.addAll(verifyAudit, audit); + form.add(Ui.section("Ledger")); + form.addAll(verifyLedger, ledger); + Ui.show(form, true); + } + + private static void result(Container box, VerificationDto v, String good) { + box.removeAll(); + if (v.intact()) { + box.add(Ui.row(Lang.tr(good), null, Lang.tr("Intact"), "BnBadgeGood", null)); + } 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)); + } + } + box.revalidate(); + } +} 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..33075a52582 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/CardsForm.java @@ -0,0 +1,126 @@ +/* + * 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.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.CardDto; +import com.codenameone.examples.bastion.api.CardLimitDto; +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.")); + } + for (final CardDto card : cards) { + final String asset = assetOf(accounts, card.accountId()); + Container face = new Container(BoxLayout.y(), card.frozen() ? "BnCardFrozen" : "BnCard"); + face.add(new Label("Bastion", "BnCardBrand")); + face.add(new Label("**** **** **** " + card.last4(), "BnCardNumber")); + face.add(new Label(card.frozen() ? Lang.tr("Frozen") + : Lang.tr("Spends up to {0} a day", Amounts.format(card.dailyLimit(), asset)), + "BnCardNote")); + 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()) { + Api.banking().unfreezeCard(card.id(), Net.to((CardDto c) -> load(content), 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)); + } + content.revalidate(); + } + + 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"), Lang.tr("Enter an amount, like 25.00."), + Lang.tr("OK"), null); + return; + } + Api.banking().cardLimit(card.id(), new CardLimitDto(units), Net.to((CardDto c) -> load(content), + (status, message) -> Dialog.show(Lang.tr("Not changed"), Lang.tr(message), + Lang.tr("OK"), null))); + } + + private static String assetOf(List accounts, String accountId) { + for (AccountDto account : accounts) { + if (account.id().equals(accountId)) { + return account.asset(); + } + } + return "USD"; + } +} 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..4fcb37dbbb9 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/DeviceSetup.java @@ -0,0 +1,112 @@ +/* + * 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 -- for an account without an authenticator app -- +/// the password again, since a stolen session alone must not be able to add +/// the device that then signs payments. +public final class DeviceSetup { + private DeviceSetup() { + } + + /// Whether this phone can sign payments. + public static boolean registered() { + return DeviceKey.deviceId() != null; + } + + /// 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 (!user.totpEnabled()) { + 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"), field, ok, + new Command(Lang.tr("Cancel"))) != ok) { + return; + } + password = field.getText(); + } + DeviceKey.register(user.username(), password, (DeviceDto device) -> done.run(), failed); + } + + /// 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) { + return status == 403 && message != null + && (message.indexOf("signature from your device") >= 0 + || message.indexOf("not attested") >= 0); + } + + /// 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 index 440f1e9e089..86e5bc2bdb1 100644 --- 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 @@ -18,70 +18,203 @@ */ 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.TextArea; 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.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 com.codenameone.examples.bastion.security.DeviceKey; +import java.util.ArrayList; +import java.util.List; -/// The signed-in home screen. The accounts, transfers, cards and exchange -/// screens arrive in the next phase; until then this is the way to the -/// Security Center, to registering this device's signing key, and out. +/// 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() { } - public static void show(final Runnable signedOut) { + public static void show() { UserDto user = Session.user(); - Form form = Ui.form("Bastion"); - form.add(Ui.title(user == null || user.displayName() == null ? "Welcome" - : user.displayName())); - form.add(Ui.muted(Lang.tr("Accounts, transfers, cards and the exchange are on their way."))); - final Label status = new Label("", "BnMuted"); - refresh(status); - form.add(status); - Button register = Ui.secondary("Register this device"); - register.addActionListener(evt -> register(status, user)); - Button center = Ui.primary("Security Center"); - center.addActionListener(evt -> SecurityCenterForm.show(() -> show(signedOut))); - Button out = Ui.secondary("Sign out"); - out.addActionListener(evt -> Session.signOut(signedOut)); - form.addAll(center, register, out); + 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}", name))); + final Container content = new Container(BoxLayout.y()); + form.add(content); Ui.show(form, true); + load(content); } - private static void refresh(Label status) { - status.setText(DeviceKey.deviceId() == null - ? Lang.tr("This device cannot sign payments yet.") - : Lang.tr("This device signs your payments.")); + private static void load(final Container content) { + Ui.loading(content); + 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 register(final Label status, UserDto user) { - final String owner = user == null ? null : user.username(); - if (owner == null) { + 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)); + + 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 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(new Label(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 Label 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()]); + Picker which = Ui.picker(names, 0); + 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; } - String password = null; - if (!user.totpEnabled()) { - // Without an authenticator app the server wants the password as - // the second proof that the owner is adding this device. - TextField field = new TextField("", Lang.tr("Password"), 20, TextArea.PASSWORD); - Command ok = new Command(Lang.tr("Continue")); - if (Dialog.show(Lang.tr("Confirm it is you"), field, ok, - new Command(Lang.tr("Cancel"))) != ok) { - return; + int index = Ui.picked(which, names); + 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(", "); } - password = field.getText(); + out.append(part); } - DeviceKey.register(owner, password, device -> refresh(status), - (code, message) -> { - status.setText(Lang.tr(message)); - status.getComponentForm().revalidate(); - }); + 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 index a5730a6a9db..5ecbdb059a1 100644 --- 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 @@ -35,16 +35,18 @@ /// 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 -- "Pick up Riley". Those go through [#tr(String, Object...)], with the +/// 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 a line for it to -/// each of the four translated files. To add a language: add its file, and its -/// code to [#CODES]. +/// 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 is the source. - public static final String[] CODES = {"en", "es", "fr", "de", "he"}; + /// The languages there is a translation for. English is the source and, + /// so far, the only one; the bundle is ready for others. + public static final String[] CODES = {"en"}; private static final String BUNDLE = "Bundle"; /// The entry each file names its own language in, in that language. 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 index 5d9dc4b6bc5..eb477a016dc 100644 --- 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 @@ -21,6 +21,7 @@ import com.codename1.ui.Button; import com.codename1.ui.Form; import com.codename1.ui.Label; +import com.codenameone.examples.bastion.Prefs; import com.codenameone.examples.bastion.security.Lock; /// What is on screen while the app is locked: nothing of the account. @@ -33,16 +34,34 @@ public static void show(final Runnable signInAgain) { Form form = Ui.form("Locked"); form.add(Ui.title("Bastion is locked")); final Label error = Ui.error(); - Button unlock = Ui.primary(Lock.biometricsAvailable() ? "Unlock" : "Sign in again"); - unlock.addActionListener(evt -> ask(signInAgain, error)); + Button unlock = Ui.primary(biometric() ? "Unlock" : "Sign in again"); + unlock.addActionListener(evt -> { + if (biometric()) { + ask(signInAgain, error); + } else { + signInAgain.run(); + } + }); form.addAll(error, unlock); // Locked screens are sensitive too: the last frame before the lock // must not survive in the recent-apps list. Ui.show(form, true); - ask(signInAgain, error); + if (biometric()) { + ask(signInAgain, error); + } + } + + /// Whether to ask for a biometric: the device has one and the user chose + /// it during onboarding or in Settings. Otherwise the way back in is the + /// password, by signing in again. + private static boolean biometric() { + return Lock.biometricsAvailable() && Prefs.biometricUnlock(); } private static void ask(Runnable signInAgain, final Label error) { + if (!biometric()) { + return; + } Lock.unlock(signInAgain, () -> Ui.showError(error, Lang.tr("Not recognised. Try again."))); } } 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..caf8e019d8b --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/MarketForm.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.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.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.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 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)); + } + + 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 Label 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); + Api.exchange().place(key[0], 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(); + }, (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, Label 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..9b6438a129f --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/Nav.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.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(Nav::home)); + bar.addMaterialCommandToSideMenu(Lang.tr("Cards"), FontImage.MATERIAL_CREDIT_CARD, + evt -> CardsForm.show(Nav::home)); + bar.addMaterialCommandToSideMenu(Lang.tr("Exchange"), FontImage.MATERIAL_SHOW_CHART, + evt -> ExchangeForm.show(Nav::home)); + bar.addMaterialCommandToSideMenu(Lang.tr("Send crypto"), FontImage.MATERIAL_CALL_MADE, + evt -> WithdrawForm.show(null, 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(Nav::home)); + if (user != null && user.admin()) { + bar.addMaterialCommandToSideMenu(Lang.tr("Administration"), + FontImage.MATERIAL_ADMIN_PANEL_SETTINGS, evt -> AdminForm.show(Nav::home)); + } + bar.addMaterialCommandToSideMenu(Lang.tr("Sign out"), FontImage.MATERIAL_LOGOUT, + evt -> signOut()); + } + + /// The dashboard; for an admin with no customer accounts, administration. + public static void home() { + UserDto user = Session.user(); + if (user != null && !user.customer() && user.admin()) { + AdminForm.show(null); + } else { + HomeForm.show(); + } + } + + public static void signOut() { + Session.signOut(signedOut); + } + + /// A form of the signed-in app: a back arrow to `back` (or none, for the + /// dashboard) and the menu. + public static Form form(String title, Runnable back) { + Form form = Ui.form(title, back); + 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..7b4e0633f2e --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/OnboardingForm.java @@ -0,0 +1,383 @@ +/* + * 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.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.Lock; +import com.codenameone.examples.bastion.security.Protect; +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, +/// copyable through the clipboard that clears itself. +/// 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 Label 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) { + 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(Net.to((TotpEnrollmentDto enrollment) -> + showSecret(content, enrollment, done), + (status, message) -> Ui.failed(content, message, () -> totp(done, skipped)))); + } + + private static void showSecret(final Container content, final TotpEnrollmentDto enrollment, + 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(new Label(group(enrollment.secret()), "BnSecret")); + Button copy = Ui.secondary("Copy key"); + copy.addActionListener(evt -> { + Protect.copy(enrollment.secret()); + Ui.toast("Copied. The clipboard clears itself in a minute."); + }); + 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 Label 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()), + 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()), + 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"); + final StringBuilder all = new StringBuilder(); + for (String one : codes) { + grid.add(new Label(one, "BnSecret")); + all.append(one).append('\n'); + } + form.add(grid); + Button copy = Ui.secondary("Copy codes"); + copy.addActionListener(evt -> { + Protect.copy(all.toString()); + Ui.toast("Copied. The clipboard clears itself in a minute."); + }); + Button saved = Ui.primary("I have saved them"); + saved.addActionListener(evt -> done.run()); + form.addAll(copy, 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 Label 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()) { + deviceStep(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, a " + + "biometric unlocks it; with it off, you sign in again. The device passcode " + + "is never accepted."))); + Button yes = Ui.primary("Use biometrics"); + yes.addActionListener(evt -> { + Prefs.setBiometricUnlock(true); + deviceStep(signedIn); + }); + Button no = Ui.secondary("Not now"); + no.addActionListener(evt -> { + Prefs.setBiometricUnlock(false); + deviceStep(signedIn); + }); + form.addAll(yes, no); + Ui.show(form, false); + } + + // --------------------------------------------------------------- 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 Label 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..30b29848b54 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/PayeesForm.java @@ -0,0 +1,126 @@ +/* + * 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, 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) { + Api.banking().renamePayee(payee.id(), new NewPayeeDto(payee.email(), + name.getText().trim()), Net.to((PayeeDto done) -> load(content), failed)); + } + } else if (picked == remove && Ui.confirm("Remove payee", + Lang.tr("Remove {0}? Adding them again starts a new cooling-off.", payee.nickname()), + "Remove")) { + Api.banking().removePayee(payee.id(), Net.to((PayeeDto done) -> load(content), failed)); + } + } +} 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..a7d090a34ad --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/ReceiptForm.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.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.ReceiptDto; +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 { + private ReceiptForm() { + } + + public static void show(ReceiptDto receipt, final Runnable done) { + Form form = Nav.form("Done", null); + Container hero = new Container(BoxLayout.y(), "BnHero"); + hero.add(new Label(Lang.tr("Sent"), "BnHeroLabel")); + hero.add(new Label(Amounts.format(receipt.amount(), receipt.asset()), "BnHeroAmount")); + form.add(hero); + form.add(Ui.row(Lang.tr("Reference"), null, receipt.entryId(), "BnRowValueMono", null)); + 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.row(Lang.tr("Note"), null, receipt.memo(), null, null)); + } + Button ok = Ui.primary("Done"); + ok.addActionListener(evt -> done.run()); + form.add(ok); + Ui.show(form, true); + } +} 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..c3d49c7c198 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/SettingsForm.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.ui; + +import com.codename1.components.Switch; +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.BorderLayout; +import com.codenameone.examples.bastion.Prefs; +import com.codenameone.examples.bastion.api.UserDto; +import com.codenameone.examples.bastion.net.Session; +import com.codenameone.examples.bastion.security.Lock; + +/// 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(Prefs.biometricUnlock()); + bio.addActionListener(evt -> Prefs.setBiometricUnlock(bio.isValue())); + Container row = new Container(new BorderLayout(), "BnRow"); + row.add(BorderLayout.CENTER, new Label(Lang.tr("Unlock with fingerprint or face"), + "BnRowTitle")); + row.add(BorderLayout.EAST, bio); + form.add(row); + } else { + form.add(Ui.row(Lang.tr("Unlock with fingerprint or face"), + Lang.tr("This device has no biometric enrolled. The lock asks for your " + + "password."), Lang.tr("Off"), "BnBadgeWarn", null)); + } + 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)))); + 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)))); + Button center = Ui.secondary("Security Center"); + center.addActionListener(evt -> SecurityCenterForm.show(() -> show(back))); + form.add(center); + Ui.show(form, false); + } +} 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 index 2264a5a4293..f5402e31e0c 100644 --- 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 @@ -59,6 +59,9 @@ public static void show(final Runnable signedIn) { (status, message) -> Ui.showError(error, Lang.tr(message)))); form.add(passkey); } + Button join = Ui.secondary("Open an account"); + join.addActionListener(evt -> OnboardingForm.show(() -> show(signedIn), signedIn)); + form.add(join); Ui.show(form, false); } 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..51335df9a4d --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/TransferForm.java @@ -0,0 +1,218 @@ +/* + * 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.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)))); + } + + 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 Picker from = Ui.picker(fromLabels, initial); + final Container target = new Container(BoxLayout.y()); + final Picker[] to = new Picker[1]; + final boolean[] payee = {toPayee}; + final String[][] toLabels = 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); + if (toLabels[0].length == 0) { + target.add(Ui.empty("Add a payee first, under Payees.")); + to[0] = null; + } else { + to[0] = Ui.picker(toLabels[0], payee[0] ? 0 : Math.min(1, toLabels[0].length - 1)); + target.add(to[0]); + } + target.revalidate(); + }); + final TextField amount = Ui.field("Amount", TextField.DECIMAL); + final TextField memo = Ui.field("Note (optional)", TextField.ANY); + final Label 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.picked(from, fromLabels))); + if (to[0] == null) { + Ui.showError(error, Lang.tr("Choose who to pay.")); + return; + } + int picked = Ui.picked(to[0], toLabels[0]); + long units = Amounts.parse(amount.getText(), source.asset()); + if (units <= 0) { + Ui.showError(error, Lang.tr("Enter an amount, like 25.00.")); + 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, 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 Label error, final TransferDto transfer, + final String key, final Runnable back) { + review.setEnabled(false); + DeviceSetup.withDevice(failed -> Api.banking().transfer(key, + RequestSigner.sign("POST", TARGET, transfer), transfer, + Net.to((ReceiptDto receipt) -> ReceiptForm.show(receipt, 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; + } + + 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 index 5474454a651..3a312a656ae 100644 --- 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 @@ -19,12 +19,22 @@ package com.codenameone.examples.bastion.ui; import com.codename1.components.SpanLabel; +import com.codename1.components.ToastBar; +import com.codename1.l10n.SimpleDateFormat; import com.codename1.ui.Button; import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; import com.codename1.ui.Form; 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.GridLayout; +import com.codename1.ui.spinner.Picker; +import java.util.Date; import com.codenameone.examples.bastion.security.Lock; import com.codenameone.examples.bastion.security.Protect; @@ -99,4 +109,154 @@ public static TextField field(String hint, int constraint) { public static Component spacer() { return new Label(" ", "BnSpacer"); } + + /// A form with a back arrow that runs `back`, and the app's menu. + 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, new Label(value, valueUiid == null ? "BnRowValue" : valueUiid)); + } + if (tap != null) { + row.setLeadComponent(lead(row, tap)); + } + return row; + } + + /// 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. + public static Container across(Component... parts) { + Container row = new Container(new GridLayout(1, parts.length)); + row.addAll(parts); + return row; + } + + /// 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 `labels`, starting at `initial`. + public static Picker picker(String[] labels, int initial) { + Picker picker = new Picker(); + picker.setType(Display.PICKER_TYPE_STRINGS); + picker.setStrings(labels); + if (labels.length > 0) { + picker.setSelectedString(labels[Math.max(0, Math.min(initial, labels.length - 1))]); + } + picker.setUIID("BnField"); + return picker; + } + + /// The index `picker` is on, among the `labels` it was made with. + public static int picked(Picker picker, String[] labels) { + String selected = picker.getSelectedString(); + for (int iter = 0; iter < labels.length; iter++) { + if (labels[iter].equals(selected)) { + return iter; + } + } + return -1; + } + + /// 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 rebuildable form: `content` is emptied and refilled on every load. + /// While the first answer is on its way there is a line saying so; a + /// failure shows the reason and a way to try again. + public static void loading(Container content) { + content.removeAll(); + content.add(muted(Lang.tr("Loading..."))); + content.revalidate(); + } + + public static void failed(final Container content, String message, final Runnable retry) { + content.removeAll(); + Label 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/WithdrawForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/WithdrawForm.java new file mode 100644 index 00000000000..835befbc363 --- /dev/null +++ b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/WithdrawForm.java @@ -0,0 +1,244 @@ +/* + * 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.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())) : ""); + content.add(Ui.row(address.label(), detail, Lang.tr(waiting ? "Waiting" : "Ready"), + waiting ? "BnBadgeWarn" : "BnBadgeGood", () -> remove(address, again))); + } + 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 Picker wallet = Ui.picker(walletLabels, initial); + 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 Runnable retarget = () -> { + AccountDto from = wallets.get(Math.max(0, Ui.picked(wallet, walletLabels))); + 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()]; + for (int iter = 0; iter < usable.size(); iter++) { + 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(toLabels[0], 0); + target.add(to[0]); + } + target.revalidate(); + }; + wallet.addActionListener(evt -> retarget.run()); + retarget.run(); + final TextField amount = Ui.field("Amount", TextField.DECIMAL); + final Label 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.picked(wallet, walletLabels))); + if (to[0] == null) { + Ui.showError(error, Lang.tr("Choose an address.")); + return; + } + CryptoAddressDto address = usable.get(Math.max(0, Ui.picked(to[0], toLabels[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, withdrawal), withdrawal, + Net.to((ReceiptDto receipt) -> ReceiptForm.show(receipt, 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, 0); + 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(coin.getSelectedString(), + address.getText().trim(), label.getText().trim()); + DeviceSetup.withDevice(failed -> Api.exchange().addAddress( + RequestSigner.sign("POST", ADDRESSES, 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; + } + Api.exchange().removeAddress(address.id(), Net.to((CryptoAddressDto gone) -> done.run(), + (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_en.properties b/scripts/bastion/common/src/main/l10n/Bundle_en.properties new file mode 100644 index 00000000000..ccdbd7f9ad1 --- /dev/null +++ b/scripts/bastion/common/src/main/l10n/Bundle_en.properties @@ -0,0 +1,314 @@ +# 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. Messages the server +# sends (refusals, limits) arrive in the server's language and are not here. +@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 +Enter\ an\ amount,\ like\ 25.00.=Enter an amount, like 25.00. +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 +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. +Copy\ codes=Copy codes +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 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..e94d41ceaae --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/money/AmountsTest.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.money; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Amounts go to text and back exactly, without a `double` anywhere. +class AmountsTest { + @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)); + } +} 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..1870c42186a --- /dev/null +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/BundleTest.java @@ -0,0 +1,74 @@ +/* + * 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; + +/// 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 is the plain case: a single literal handed to `Lang.tr` or +/// to one of the builders that translate their argument. A sentence built of +/// several literals is the writer's to add by hand. +class BundleTest { + private static final Pattern CALL = Pattern.compile( + "(?:Lang\\.tr|Ui\\.(?:form|title|primary|secondary|section|empty|field|toast)|Nav\\.form)" + + "\\(\\s*\"((?:[^\"\\\\]|\\\\.)*)\"\\s*[,)]"); + + @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) { + Matcher m = CALL.matcher(new String(Files.readAllBytes(source), StandardCharsets.UTF_8)); + while (m.find()) { + String key = m.group(1).replace("\\\"", "\"").replace("\\\\", "\\"); + if (key.length() > 0 && !bundle.containsKey(key)) { + missing.add(source.getFileName() + ": " + key); + } + } + } + assertEquals("", String.join("\n", missing)); + } +} 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 From 081612aa6d522af69a2a66d6b63698f56eee5729 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:06:37 +0300 Subject: [PATCH 08/53] Bastion sample: device key on core ES256, assets from the server, passkeys in Settings, pickers by id DeviceKey/RequestSigner use KeyGenerator.ecP256 and Signature SHA256withECDSA (DER to JOSE in RequestSigner); the hand-rolled P256 is gone. GET /api/assets serves Ledger.assets and replaces the client's copied table. GET/DELETE /api/account/passkeys plus a Settings section that adds a passkey through a fresh kept sign-in. Pickers carry ids and make repeated labels distinct. Co-Authored-By: Claude Opus 5.5 --- .../bastion/account/AccountEndpoint.java | 17 +- .../examples/bastion/account/PasskeyList.java | 92 +++++++ .../bastion/money/BankingEndpoint.java | 11 + .../examples/bastion/MoneyTest.java | 59 ++++ .../examples/bastion/money/Assets.java | 56 +++- .../examples/bastion/net/Session.java | 1 + .../examples/bastion/security/DeviceKey.java | 24 +- .../examples/bastion/security/P256.java | 253 ------------------ .../bastion/security/RequestSigner.java | 39 ++- .../examples/bastion/ui/HomeForm.java | 12 +- .../examples/bastion/ui/SettingsForm.java | 90 +++++++ .../examples/bastion/ui/TransferForm.java | 32 ++- .../codenameone/examples/bastion/ui/Ui.java | 52 +++- .../examples/bastion/ui/WithdrawForm.java | 19 +- .../common/src/main/l10n/Bundle_en.properties | 7 + .../examples/bastion/money/AmountsTest.java | 15 ++ .../examples/bastion/security/P256Test.java | 81 ------ .../bastion/security/RequestSignerTest.java | 25 +- .../examples/bastion/ui/PickerChoiceTest.java | 41 +++ .../examples/bastion/api/AccountApi.java | 13 + .../examples/bastion/api/BankingApi.java | 5 + .../examples/bastion/api/PasskeyDto.java | 27 ++ 22 files changed, 579 insertions(+), 392 deletions(-) create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/PasskeyList.java delete mode 100644 scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/P256.java delete mode 100644 scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/P256Test.java create mode 100644 scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/ui/PickerChoiceTest.java create mode 100644 scripts/bastion/shared/src/main/java/com/codenameone/examples/bastion/api/PasskeyDto.java 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 index 0a07fedcb88..d6e9c2ca780 100644 --- 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 @@ -21,6 +21,7 @@ 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.PasswordChangeDto; import com.codenameone.examples.bastion.api.RecoveryCodesDto; import com.codenameone.examples.bastion.api.RegisterDto; @@ -28,6 +29,8 @@ import com.codenameone.examples.bastion.api.TotpEnrollmentDto; 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. @@ -35,10 +38,12 @@ public class AccountEndpoint implements AccountApiServer { private final Accounts accounts; private final SecondFactor secondFactor; + private final PasskeyList passkeys; - public AccountEndpoint(Accounts accounts, SecondFactor secondFactor) { + public AccountEndpoint(Accounts accounts, SecondFactor secondFactor, PasskeyList passkeys) { this.accounts = accounts; this.secondFactor = secondFactor; + this.passkeys = passkeys; } @Override @@ -71,4 +76,14 @@ public RecoveryCodesDto confirmTotp(TotpCodeDto code) throws Exception { 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) throws Exception { + return passkeys.remove(Caller.name(), id); + } } 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..21dc93137db --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/account/PasskeyList.java @@ -0,0 +1,92 @@ +/* + * 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.Base64Url; +import com.codename1.backend.ResponseStatusException; +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 and removing need only the caller's token: +/// removing a passkey takes nothing away the password and second factor do +/// not still guard, so a stolen token can at worst make its owner sign in the +/// longer way -- and 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 new ResponseStatusException(404, "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/money/BankingEndpoint.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/money/BankingEndpoint.java index ee47c7c0813..46c73fda3d9 100644 --- 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 @@ -23,6 +23,7 @@ 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; @@ -37,6 +38,7 @@ 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.LedgerAccount; @@ -82,6 +84,15 @@ public BankingEndpoint(Ledger ledger, Money money, Payees payees, Cards cards, 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(); 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 index cd4aec862d1..7616a85355c 100644 --- 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 @@ -23,6 +23,10 @@ import com.codename1.backend.Json; import com.codename1.backend.annotations.Autowired; import com.codename1.backend.security.crypto.Der; +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; @@ -44,6 +48,7 @@ import java.util.Map; import java.util.UUID; +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 org.junit.jupiter.api.Assertions.assertEquals; @@ -62,6 +67,12 @@ class MoneyTest { @Autowired private MockMvc mvc; + @Autowired + private UserCredentialRepository passkeys; + + @Autowired + private PublicKeyCredentialUserEntityRepository passkeyHolders; + @Autowired private JwkSource signingKeys; @@ -196,6 +207,54 @@ private static String internal(String from, String to, long amount) { + amount + ",\"memo\":\"save\"}"; } + @Test + void listsAndRemovesOnlyTheCallersPasskeys() throws Exception { + Customer c = new Customer(false); + Customer other = new Customer(false); + byte[] handle = UUID.randomUUID().toString().getBytes("UTF-8"); + passkeyHolders.save(new PublicKeyCredentialUserEntity(c.name, handle, c.name)); + byte[] credentialId = UUID.randomUUID().toString().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()); + + assertEquals(404, other.send(delete("/api/account/passkeys/" + id)).getStatus()); + MockResponse removed = c.send(delete("/api/account/passkeys/" + id)); + assertEquals(200, removed.getStatus(), removed.getContentAsString()); + assertEquals(0, ((List) Json.parse(removed.getContentAsString())).size()); + 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); 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 index c845933decc..053bb463c8d 100644 --- 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 @@ -18,14 +18,20 @@ */ 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; this mirrors its rows -/// (`V2__ledger.sql`) because the API has no call that lists them, and an -/// asset missing here is shown in raw units rather than guessed at. +/// 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"; @@ -33,13 +39,8 @@ public final class Assets { /// The currency balances are totalled in on the dashboard. public static final String REPORTING = "USD"; - private static final Asset[] ALL = { - new Asset("USD", "US Dollar", FIAT, 2), - new Asset("EUR", "Euro", FIAT, 2), - new Asset("BTC", "Bitcoin", CRYPTO, 8), - // 9, not the chain's 18: see the comment beside the row on the server. - new Asset("ETH", "Ether", CRYPTO, 9), - }; + private static Asset[] all = new Asset[0]; + private static boolean loaded; /// One asset. public static final class Asset { @@ -63,9 +64,38 @@ public boolean isCrypto() { 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) { + for (Asset asset : all) { if (asset.code.equals(code)) { return asset; } @@ -87,8 +117,8 @@ public static boolean isCrypto(String code) { /// Every asset, fiat first. public static Asset[] all() { - Asset[] copy = new Asset[ALL.length]; - System.arraycopy(ALL, 0, copy, 0, ALL.length); + 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/Session.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/net/Session.java index f7dff5dd00c..def94530dc6 100644 --- 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 @@ -456,6 +456,7 @@ public void onSucess(Throwable err) { /// Ends a session kept for [#addPasskey] that was not used. public static void endPasskeySession() { + keepForPasskey = false; if (ceremonyCookie != null) { endServerSession(ceremonyServer, ceremonyCookie); } 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 index 817b3604f4c..0cef2612083 100644 --- 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 @@ -20,6 +20,10 @@ 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.SecureRandom; import com.codename1.security.SecureStorage; import com.codename1.security.vault.AssociatedData; @@ -35,7 +39,9 @@ /// This device's signing key: the second thing, after the session, that the /// server asks for before money leaves an account. /// -/// The private key is a P-256 scalar ([P256]). It is kept sealed: an +/// The key is an EC P-256 pair from [KeyGenerator#ecP256()], 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 /// a 256-bit wrapping key that lives in [SecureStorage] -- the iOS Keychain or @@ -50,7 +56,7 @@ public final class DeviceKey { private static final String ID_KEY = "bastion.device.id"; private static final String OWNER_KEY = "bastion.device.owner"; - private static byte[] cached; + private static PrivateKey cached; private DeviceKey() { } @@ -70,7 +76,7 @@ public static String owner() { /// 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 byte[] privateKey() { + public static PrivateKey privateKey() { if (Lock.isLocked()) { return null; } @@ -84,7 +90,8 @@ public static byte[] privateKey() { return null; } try { - cached = SecureEnvelope.parse(Base64.decodeUrlSafe(sealed)).open(wrap, aad(owner)); + 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 @@ -114,8 +121,9 @@ public static void forget() { /// 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) { - final byte[] key = P256.newPrivateKey(SecureRandom.bytes(48)); - final String publicKey = Base64.encodeUrlSafe(P256.publicKey(key)); + KeyPair pair = KeyGenerator.ecP256(); + 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 @@ -136,7 +144,7 @@ public void onSucess(Throwable err) { send(owner, password, key, publicKey, rooted, null, done, failed); } - private static void send(final String owner, String password, final byte[] key, + 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, @@ -147,7 +155,7 @@ private static void send(final String owner, String password, final byte[] key, failed.failed(0, "This device has no secure storage for the key."); return; } - byte[] sealed = SecureEnvelope.seal(wrap, "device-key", 1, aad(owner), key); + 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); diff --git a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/P256.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/P256.java deleted file mode 100644 index a5966fe863a..00000000000 --- a/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/security/P256.java +++ /dev/null @@ -1,253 +0,0 @@ -/* - * 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.Hash; -import com.codename1.security.Hmac; -import com.codename1.util.BigInteger; - -/// ECDSA over NIST P-256 with SHA-256 (ES256), in portable Java. -/// -/// The device key is P-256 because that is what the server accepts and what -/// every phone's secure hardware can hold. The framework's -/// `com.codename1.security.KeyGenerator` makes RSA keys only, and the iOS -/// port's `Signature` signs with RSA keys only, so the sample does the curve -/// arithmetic itself: identical on every port, and checked against the JDK -/// and the RFC 6979 vectors by `P256Test`. -/// -/// Signing uses RFC 6979 deterministic nonces (HMAC-SHA-256), so a weak -/// random generator cannot leak the key through a repeated `k`. This is not -/// constant time: a sample's key on a phone is not exposed to a timing -/// attacker the way a server's is, but a production app should move the key -/// into the platform keystore (non-exportable, hardware backed) once the -/// framework can generate EC keys there. -public final class P256 { - static final BigInteger P = hex( - "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff"); - static final BigInteger N = hex( - "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"); - static final BigInteger B = hex( - "5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b"); - static final BigInteger GX = hex( - "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296"); - static final BigInteger GY = hex( - "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5"); - private static final BigInteger THREE = BigInteger.valueOf(3); - - /// The DER of a P-256 SubjectPublicKeyInfo up to the point. - private static final byte[] SPKI_PREFIX = Hash.fromHex( - "3059301306072a8648ce3d020106082a8648ce3d030107034200"); - - private P256() { - } - - /// A fresh private key: 32 bytes, a scalar in `[1, n-1]`. - public static byte[] newPrivateKey(byte[] seed) { - // Reduced from 48 bytes so the bias of the modulo is negligible. - if (seed == null || seed.length < 48) { - throw new IllegalArgumentException("48 random bytes are needed"); - } - BigInteger d = new BigInteger(1, seed).mod(N.subtract(BigInteger.ONE)).add(BigInteger.ONE); - return fixed(d); - } - - /// The public key of `privateKey` as X.509 SubjectPublicKeyInfo DER, the - /// form the server takes (`DeviceRegistrationDto.publicKey`, base64url). - public static byte[] publicKey(byte[] privateKey) { - BigInteger[] q = toAffine(multiply(scalar(privateKey), GX, GY)); - byte[] out = new byte[SPKI_PREFIX.length + 65]; - System.arraycopy(SPKI_PREFIX, 0, out, 0, SPKI_PREFIX.length); - out[SPKI_PREFIX.length] = 4; - System.arraycopy(fixed(q[0]), 0, out, SPKI_PREFIX.length + 1, 32); - System.arraycopy(fixed(q[1]), 0, out, SPKI_PREFIX.length + 33, 32); - return out; - } - - /// Signs `message` (hashed here with SHA-256) and answers the 64-byte - /// `r || s`. - public static byte[] sign(byte[] privateKey, byte[] message) { - BigInteger d = scalar(privateKey); - byte[] digest = Hash.sha256(message); - BigInteger e = new BigInteger(1, digest); - Rfc6979 nonces = new Rfc6979(fixed(d), fixed(e.mod(N))); - while (true) { - BigInteger k = nonces.next(); - BigInteger[] point = toAffine(multiply(k, GX, GY)); - BigInteger r = point[0].mod(N); - if (r.signum() == 0) { - continue; - } - BigInteger s = inverse(k, N).multiply(e.add(r.multiply(d))).mod(N); - if (s.signum() == 0) { - continue; - } - byte[] out = new byte[64]; - System.arraycopy(fixed(r), 0, out, 0, 32); - System.arraycopy(fixed(s), 0, out, 32, 32); - return out; - } - } - - private static BigInteger scalar(byte[] privateKey) { - if (privateKey == null || privateKey.length != 32) { - throw new IllegalArgumentException("A P-256 private key is 32 bytes"); - } - BigInteger d = new BigInteger(1, privateKey); - if (d.signum() == 0 || d.compareTo(N) >= 0) { - throw new IllegalArgumentException("Not a P-256 private key"); - } - return d; - } - - // ---------------------------------------------------- curve arithmetic - // Jacobian coordinates {X, Y, Z}: x = X/Z^2, y = Y/Z^3; null is infinity. - - static BigInteger[] multiply(BigInteger k, BigInteger x, BigInteger y) { - BigInteger[] base = {x, y, BigInteger.ONE}; - BigInteger[] acc = null; - for (int bit = k.bitLength() - 1; bit >= 0; bit--) { - acc = twice(acc); - if (k.testBit(bit)) { - acc = add(acc, base); - } - } - return acc; - } - - private static BigInteger[] twice(BigInteger[] p) { - if (p == null || p[1].signum() == 0) { - return null; - } - BigInteger delta = p[2].multiply(p[2]).mod(P); - BigInteger gamma = p[1].multiply(p[1]).mod(P); - BigInteger beta = p[0].multiply(gamma).mod(P); - BigInteger alpha = THREE.multiply(p[0].subtract(delta)).multiply(p[0].add(delta)).mod(P); - BigInteger x3 = alpha.multiply(alpha).subtract(beta.shiftLeft(3)).mod(P); - BigInteger z3 = p[1].add(p[2]).pow(2).subtract(gamma).subtract(delta).mod(P); - BigInteger y3 = alpha.multiply(beta.shiftLeft(2).subtract(x3)) - .subtract(gamma.multiply(gamma).shiftLeft(3)).mod(P); - return new BigInteger[] {x3, y3, z3}; - } - - private static BigInteger[] add(BigInteger[] p, BigInteger[] q) { - if (p == null) { - return q; - } - if (q == null) { - return p; - } - BigInteger z1z1 = p[2].multiply(p[2]).mod(P); - BigInteger z2z2 = q[2].multiply(q[2]).mod(P); - BigInteger u1 = p[0].multiply(z2z2).mod(P); - BigInteger u2 = q[0].multiply(z1z1).mod(P); - BigInteger s1 = p[1].multiply(q[2]).multiply(z2z2).mod(P); - BigInteger s2 = q[1].multiply(p[2]).multiply(z1z1).mod(P); - if (u1.equals(u2)) { - return s1.equals(s2) ? twice(p) : null; - } - BigInteger h = u2.subtract(u1).mod(P); - BigInteger i = h.shiftLeft(1).pow(2).mod(P); - BigInteger j = h.multiply(i).mod(P); - BigInteger r = s2.subtract(s1).shiftLeft(1).mod(P); - BigInteger v = u1.multiply(i).mod(P); - BigInteger x3 = r.multiply(r).subtract(j).subtract(v.shiftLeft(1)).mod(P); - BigInteger y3 = r.multiply(v.subtract(x3)).subtract(s1.multiply(j).shiftLeft(1)).mod(P); - BigInteger z3 = p[2].add(q[2]).pow(2).subtract(z1z1).subtract(z2z2).multiply(h).mod(P); - return new BigInteger[] {x3, y3, z3}; - } - - static BigInteger[] toAffine(BigInteger[] p) { - if (p == null) { - throw new IllegalStateException("point at infinity"); - } - BigInteger zi = inverse(p[2], P); - BigInteger zi2 = zi.multiply(zi).mod(P); - BigInteger x = p[0].multiply(zi2).mod(P); - BigInteger y = p[1].multiply(zi2).multiply(zi).mod(P); - // y^2 = x^3 - 3x + b: a wrong result never leaves this class. - BigInteger left = y.multiply(y).mod(P); - BigInteger right = x.pow(3).subtract(THREE.multiply(x)).add(B).mod(P); - if (!left.equals(right)) { - throw new IllegalStateException("point is not on the curve"); - } - return new BigInteger[] {x, y}; - } - - /// `value` to the power -1 modulo the prime `m`, by Fermat: value^(m-2). - /// - /// Not `BigInteger.modInverse`: the framework's answers wrong for 256-bit - /// operands (it agrees with `java.math` on small numbers only), which - /// `P256Test` would catch at once. - static BigInteger inverse(BigInteger value, BigInteger m) { - return value.mod(m).modPow(m.subtract(BigInteger.valueOf(2)), m); - } - - /// `value` as exactly 32 unsigned big-endian bytes. - static byte[] fixed(BigInteger value) { - byte[] raw = value.toByteArray(); - byte[] out = new byte[32]; - int copy = Math.min(raw.length, 32); - System.arraycopy(raw, raw.length - copy, out, 32 - copy, copy); - return out; - } - - private static BigInteger hex(String text) { - return new BigInteger(text, 16); - } - - /// RFC 6979 section 3.2 with HMAC-SHA-256, for a 256-bit order. - private static final class Rfc6979 { - private byte[] v = new byte[32]; - private byte[] k = new byte[32]; - - Rfc6979(byte[] x, byte[] h1) { - for (int iter = 0; iter < 32; iter++) { - v[iter] = 1; - } - k = Hmac.sha256(k, concat(v, new byte[] {0}, x, h1)); - v = Hmac.sha256(k, v); - k = Hmac.sha256(k, concat(v, new byte[] {1}, x, h1)); - v = Hmac.sha256(k, v); - } - - BigInteger next() { - while (true) { - v = Hmac.sha256(k, v); - BigInteger candidate = new BigInteger(1, v); - if (candidate.signum() > 0 && candidate.compareTo(N) < 0) { - // Moved on now, so a retry (r or s zero) gets a new k. - k = Hmac.sha256(k, concat(v, new byte[] {0}, new byte[0], new byte[0])); - v = Hmac.sha256(k, v); - return candidate; - } - k = Hmac.sha256(k, concat(v, new byte[] {0}, new byte[0], new byte[0])); - v = Hmac.sha256(k, v); - } - } - - private static byte[] concat(byte[] a, byte[] b, byte[] c, byte[] d) { - byte[] out = new byte[a.length + b.length + c.length + d.length]; - System.arraycopy(a, 0, out, 0, a.length); - System.arraycopy(b, 0, out, a.length, b.length); - System.arraycopy(c, 0, out, a.length + b.length, c.length); - System.arraycopy(d, 0, out, a.length + b.length + c.length, d.length); - return out; - } - } -} 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 index 657c667b660..f9d54360e6f 100644 --- 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 @@ -20,7 +20,9 @@ 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; @@ -43,7 +45,7 @@ private RequestSigner() { /// needs one, and the app offers to register the device. public static String sign(String method, String target, Object body) { String deviceId = DeviceKey.deviceId(); - byte[] key = DeviceKey.privateKey(); + PrivateKey key = DeviceKey.privateKey(); if (deviceId == null || key == null) { return null; } @@ -62,11 +64,40 @@ public static byte[] bodyBytes(Object body) { /// The header for the given parts; the deterministic core of /// [#sign(String, String, Object)], which the tests check against the /// server's form. - public static String header(String deviceId, byte[] privateKey, String method, + public static String header(String deviceId, PrivateKey privateKey, String method, String target, byte[] body, String nonce, long ts) { String text = SignedRequest.canonical(method, target, Hash.sha256(body), nonce, ts); - byte[] signature = P256.sign(privateKey, utf8(text)); - return SignedRequest.header(deviceId, ts, nonce, Base64.encodeUrlSafe(signature)); + 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. 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 index 86e5bc2bdb1..1b8c624c733 100644 --- 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 @@ -64,12 +64,12 @@ public static void show() { private static void load(final Container content) { Ui.loading(content); - Api.banking().accounts(Net.to((List accounts) -> + 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)))); + (status, message) -> Ui.failed(content, message, () -> load(content))))); } private static void fill(final Container content, List accounts, @@ -186,7 +186,11 @@ private static void openAccount(final Container content) { } } final String[] names = labels.toArray(new String[labels.size()]); - Picker which = Ui.picker(names, 0); + 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]; + } + Picker which = Ui.picker(ids, names, null); TextField name = Ui.field("Name (optional)", TextField.ANY); Container body = new Container(BoxLayout.y()); body.addAll(which, name); @@ -195,7 +199,7 @@ private static void openAccount(final Container content) { != open) { return; } - int index = Ui.picked(which, names); + int index = Ui.indexOf(ids, Ui.pickedId(which)); if (index < 0) { return; } 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 index c3d49c7c198..6108bcece5b 100644 --- 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 @@ -20,15 +20,26 @@ 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.Prefs; +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.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 { @@ -61,6 +72,9 @@ public static void show(final Runnable back) { 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 @@ -74,4 +88,80 @@ public static void show(final Runnable back) { form.add(center); Ui.show(form, false); } + + // ------------------------------------------------------------- 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))); + } + 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; + } + Api.account().removePasskey(passkey.id(), 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"), 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/TransferForm.java b/scripts/bastion/common/src/main/java/com/codenameone/examples/bastion/ui/TransferForm.java index 51335df9a4d..98a98b77c5d 100644 --- 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 @@ -100,21 +100,25 @@ private static void fill(final Container content, final List all, return; } final String[] fromLabels = labels(accounts); - final Picker from = Ui.picker(fromLabels, initial); + 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(toLabels[0], payee[0] ? 0 : Math.min(1, toLabels[0].length - 1)); + to[0] = Ui.picker(toIds[0], toLabels[0], + toIds[0][payee[0] ? 0 : Math.min(1, toIds[0].length - 1)]); target.add(to[0]); } target.revalidate(); @@ -137,12 +141,16 @@ private static void fill(final Container content, final List all, content.addAll(amount, memo, error, review); review.addActionListener(evt -> { Ui.showError(error, null); - AccountDto source = accounts.get(Math.max(0, Ui.picked(from, fromLabels))); + 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.picked(to[0], toLabels[0]); + 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, Lang.tr("Enter an amount, like 25.00.")); @@ -207,6 +215,22 @@ static String[] labels(List accounts) { 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++) { 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 index 3a312a656ae..1814f25d90f 100644 --- 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 @@ -198,29 +198,61 @@ private static void select(Container row, int index) { row.repaint(); } - /// A picker of `labels`, starting at `initial`. - public static Picker picker(String[] labels, int initial) { + /// 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); - picker.setStrings(labels); - if (labels.length > 0) { - picker.setSelectedString(labels[Math.max(0, Math.min(initial, labels.length - 1))]); + // 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 index `picker` is on, among the `labels` it was made with. - public static int picked(Picker picker, String[] labels) { - String selected = picker.getSelectedString(); - for (int iter = 0; iter < labels.length; iter++) { - if (labels[iter].equals(selected)) { + /// 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) { 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 index 835befbc363..1c97ab94474 100644 --- 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 @@ -122,13 +122,16 @@ 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 Picker wallet = Ui.picker(walletLabels, initial); + 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.picked(wallet, walletLabels))); + 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) { @@ -137,14 +140,16 @@ private static Container sendPanel(final List wallets, int initial, } 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(toLabels[0], 0); + to[0] = Ui.picker(toIds[0], toLabels[0], null); target.add(to[0]); } target.revalidate(); @@ -159,12 +164,12 @@ private static Container sendPanel(final List wallets, int initial, final String[] key = new String[1]; send.addActionListener(evt -> { Ui.showError(error, null); - final AccountDto from = wallets.get(Math.max(0, Ui.picked(wallet, walletLabels))); + 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.picked(to[0], toLabels[0]))); + 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." @@ -201,7 +206,7 @@ private static void addAddress(final Runnable done) { } } final String[] codes = coins.toArray(new String[coins.size()]); - Picker coin = Ui.picker(codes, 0); + 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()); @@ -210,7 +215,7 @@ private static void addAddress(final Runnable done) { if (Dialog.show(Lang.tr("Add an address"), body, ok, new Command(Lang.tr("Cancel"))) != ok) { return; } - final NewCryptoAddressDto added = new NewCryptoAddressDto(coin.getSelectedString(), + final NewCryptoAddressDto added = new NewCryptoAddressDto(Ui.pickedId(coin), address.getText().trim(), label.getText().trim()); DeviceSetup.withDevice(failed -> Api.exchange().addAddress( RequestSigner.sign("POST", ADDRESSES, added), added, diff --git a/scripts/bastion/common/src/main/l10n/Bundle_en.properties b/scripts/bastion/common/src/main/l10n/Bundle_en.properties index ccdbd7f9ad1..91de4914e5b 100644 --- a/scripts/bastion/common/src/main/l10n/Bundle_en.properties +++ b/scripts/bastion/common/src/main/l10n/Bundle_en.properties @@ -312,3 +312,10 @@ Run\ on\ a\ phone;\ the\ simulator\ reports\ nothing=Run on a phone; the simulat 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 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 index e94d41ceaae..4f3707b49f1 100644 --- 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 @@ -18,12 +18,27 @@ */ 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")); diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/P256Test.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/P256Test.java deleted file mode 100644 index 98cec447694..00000000000 --- a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/security/P256Test.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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.security.KeyFactory; -import java.security.KeyPairGenerator; -import java.security.interfaces.ECPrivateKey; -import java.security.interfaces.ECPublicKey; -import java.security.spec.ECGenParameterSpec; -import java.security.spec.X509EncodedKeySpec; -import java.util.Arrays; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; - -/// The curve arithmetic against RFC 6979 and the JDK. -class P256Test { - private static byte[] hex(String text) { - byte[] out = new byte[text.length() / 2]; - for (int iter = 0; iter < out.length; iter++) { - out[iter] = (byte) Integer.parseInt(text.substring(iter * 2, iter * 2 + 2), 16); - } - return out; - } - - /// RFC 6979 A.2.5, P-256 with SHA-256, message "sample". - @Test - void rfc6979Vector() throws Exception { - byte[] x = hex("c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721"); - byte[] spki = P256.publicKey(x); - assertArrayEquals(hex("60fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6"), - Arrays.copyOfRange(spki, spki.length - 64, spki.length - 32)); - assertArrayEquals(hex("7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299"), - Arrays.copyOfRange(spki, spki.length - 32, spki.length)); - byte[] sig = P256.sign(x, "sample".getBytes("UTF-8")); - assertArrayEquals(hex("efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716" - + "f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8"), sig); - } - - /// A key the JDK made has the public point this class computes, and the - /// SubjectPublicKeyInfo this class writes is one the JDK reads. - @Test - void agreesWithTheJdk() throws Exception { - KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); - generator.initialize(new ECGenParameterSpec("secp256r1")); - for (int round = 0; round < 5; round++) { - java.security.KeyPair pair = generator.generateKeyPair(); - byte[] d = toFixed(((ECPrivateKey) pair.getPrivate()).getS().toByteArray()); - byte[] mine = P256.publicKey(d); - assertArrayEquals(pair.getPublic().getEncoded(), mine); - ECPublicKey read = (ECPublicKey) KeyFactory.getInstance("EC") - .generatePublic(new X509EncodedKeySpec(mine)); - assertEquals(((ECPublicKey) pair.getPublic()).getW(), read.getW()); - } - } - - private static byte[] toFixed(byte[] raw) { - byte[] out = new byte[32]; - int copy = Math.min(raw.length, 32); - System.arraycopy(raw, raw.length - copy, out, 32 - copy, copy); - return out; - } -} 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 index f2085eca935..3464d98138a 100644 --- 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 @@ -19,6 +19,10 @@ package com.codenameone.examples.bastion.security; import com.codenameone.examples.bastion.api.SignedRequest; +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; @@ -56,12 +60,11 @@ void canonicalFormMatchesTheServers() throws Exception { @Test void headerVerifiesWithTheJdkAsTheServerDoes() throws Exception { - byte[] seed = new byte[48]; - for (int iter = 0; iter < seed.length; iter++) { - seed[iter] = (byte) (iter * 7 + 3); - } - byte[] privateKey = P256.newPrivateKey(seed); - String header = RequestSigner.header("dev1", privateKey, "POST", + // 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.ecP256(); + String header = RequestSigner.header("dev1", pair.getPrivateKey(), "POST", "/api/accounts/a1/withdraw", BODY.getBytes("UTF-8"), NONCE, TS); assertTrue(header.startsWith("v1;device=dev1;ts=" + TS + ";nonce=" + NONCE + ";sig="), header); @@ -69,13 +72,21 @@ void headerVerifiesWithTheJdkAsTheServerDoes() throws Exception { assertEquals(64, jose.length); PublicKey key = KeyFactory.getInstance("EC").generatePublic( - new X509EncodedKeySpec(P256.publicKey(privateKey))); + new X509EncodedKeySpec(pair.getPublicKey().getEncoded())); Signature verifier = Signature.getInstance("SHA256withECDSA"); verifier.initVerify(key); verifier.update(CANONICAL.getBytes("UTF-8")); assertTrue(verifier.verify(der(jose))); } + @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(); 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/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 index 83f8bb984e5..fe492a3a360 100644 --- 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 @@ -19,12 +19,16 @@ 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.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. /// @@ -60,4 +64,13 @@ public interface AccountApi { @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. + @DELETE("/api/account/passkeys/{id}") + void removePasskey(@Path("id") String id, OnComplete>> callback); } 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 index 91f919d9d8f..ee0272a4f25 100644 --- 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 @@ -55,6 +55,11 @@ /// 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); 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) { +} From ec0fffa58f3fb7effa4d0adf65878853f4281ca7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:39:32 +0300 Subject: [PATCH 09/53] Bastion sample: CI workflow, live and web e2e, user's and implementor's guides Fixes found on PostgreSQL, MySQL and the compiled server: cancel reads the order behind the market lock, the server refuses MySQL above READ COMMITTED, and the exchange avoids JDK APIs the compiled class library lacks. The app's idle timer binds to the shown form. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bastion.yml | 575 ++++++++++++++++++ .github/workflows/check-doc-snippets.yml | 1 + .github/workflows/developer-guide-docs.yml | 5 +- .github/workflows/website-docs.yml | 1 + .../Bastion-Implementors-Guide.asciidoc | 160 +++++ .../Bastion-Users-Guide.asciidoc | 118 ++++ docs/developer-guide/developer-guide.asciidoc | 4 + docs/developer-guide/languagetool-accept.txt | 6 + scripts/bastion/README.adoc | 114 ++++ .../examples/bastion/exchange/Exchange.java | 29 +- .../bastion/exchange/MarketMaker.java | 6 +- .../examples/bastion/exchange/Markets.java | 49 +- .../bastion/ledger/IsolationCheck.java | 65 ++ .../repository/TradeOrderRepository.java | 7 + .../examples/bastion/ExchangeTest.java | 5 +- .../examples/bastion/MoneyTest.java | 45 +- .../examples/bastion/SigningVectorTest.java | 3 +- .../examples/bastion/TestKeys.java | 78 +++ .../examples/bastion/security/Lock.java | 17 +- .../examples/bastion/security/Protect.java | 6 +- scripts/bastion/e2e/LiveE2e.java | 542 +++++++++++++++++ scripts/bastion/run-e2e.sh | 76 +++ scripts/bastion/run-web-e2e.sh | 109 ++++ scripts/bastion/web-e2e.mjs | 253 ++++++++ scripts/check-copyright-headers.sh | 5 +- 25 files changed, 2217 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/bastion.yml create mode 100644 docs/developer-guide/Bastion-Implementors-Guide.asciidoc create mode 100644 docs/developer-guide/Bastion-Users-Guide.asciidoc create mode 100644 scripts/bastion/README.adoc create mode 100644 scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/IsolationCheck.java create mode 100644 scripts/bastion/backend/src/test/java/com/codenameone/examples/bastion/TestKeys.java create mode 100644 scripts/bastion/e2e/LiveE2e.java create mode 100755 scripts/bastion/run-e2e.sh create mode 100755 scripts/bastion/run-web-e2e.sh create mode 100644 scripts/bastion/web-e2e.mjs diff --git a/.github/workflows/bastion.yml b/.github/workflows/bastion.yml new file mode 100644 index 00000000000..a0444635e84 --- /dev/null +++ b/.github/workflows/bastion.yml @@ -0,0 +1,575 @@ +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' + - '.github/workflows/bastion.yml' + push: + branches: [ master ] + paths: + - 'scripts/bastion/**' + - 'vm/backend/src/**' + - 'maven/backend/**' + - 'maven/backend-test/**' + - 'CodenameOne/src/com/codename1/security/**' + - 'CodenameOne/src/com/codename1/impl/orm/**' + - 'CodenameOne/src/com/codename1/orm/**' + - 'CodenameOne/src/com/codename1/annotations/**' + - 'scripts/ci/sample-server-tests-on-database.sh' + - '.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: + 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 + + 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 + + # 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 0ad337c3125..640e6aae547 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/**' - 'docs/website/content/blog/**' - 'CodenameOne/src/**/*.java' - 'Ports/CLDC11/src/**/*.java' diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index ca08aa4fe2e..829c80378ae 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -16,6 +16,8 @@ on: - 'scripts/wayline/**' # The Savor chapters, the same way; savor.yml compiles and tests the sample. - 'scripts/savor/**' + # The Bastion chapters, the same way; bastion.yml compiles and tests it. + - 'scripts/bastion/**' # The API-name check derives the published pages from the javadoc's own # sources, and the guide links into the java.* compatibility classes -- # java/util/List.html among them. A change that renames or deletes one @@ -127,10 +129,11 @@ jobs: - 'Ports/JavaSE/**' - 'maven/core/**' - 'maven/javase/**' - # The Wayline and Savor chapters include these trees by tag, so a + # The Wayline, Savor 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/bastion/**' workflow: - '.github/workflows/developer-guide-docs.yml' diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index eb04ae7fc23..396a752d750 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/website/**' - 'scripts/skindesigner/**' - 'Ports/JavaScriptPort/**' diff --git a/docs/developer-guide/Bastion-Implementors-Guide.asciidoc b/docs/developer-guide/Bastion-Implementors-Guide.asciidoc new file mode 100644 index 00000000000..e0099dfddd5 --- /dev/null +++ b/docs/developer-guide/Bastion-Implementors-Guide.asciidoc @@ -0,0 +1,160 @@ +[[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, every security feature and the file it lives in, 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] +---- +cd scripts/bastion +CN1_PROFILE=dev backend/server.sh run +---- + +The app runs in the simulator from another terminal: + +[source,bash] +---- +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +---- + +`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 + +[cols="1,3",options="header"] +|=== +|Module |What it holds +|`shared` |The transfer objects and the `@RestClient` contracts (`BankingApi`, `ExchangeApi`, `AccountApi`, `AdminApi`), and `SignedRequest`, the canonical form both sides sign and verify. +|`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 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 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. + +`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. + +=== Security features and where they live + +==== On the server + +[cols="1,2,1",options="header"] +|=== +|Feature |What it does |Where +|Sign-in |The authorization server with the authorization-code flow and PKCE, five-minute access tokens and rotating twelve-hour refresh tokens. |`SecurityConfig.java` +|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` +|Passkeys |WebAuthn registration and sign-in; listing and removing them. |`SecurityConfig.java`, `account/PasskeyList.java` +|Lockout |Failed passwords counted per account in `login_failure`, locking it for a period after too many. |`account/SignInGuard.java` +|Rate limits |Per-address limits on sign-in, registration and transfers. |`SecurityConfig.java` +|Headers |`nosniff`, frame denial, a strict referrer policy and HSTS over TLS on every answer. |`SecurityConfig.java` +|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` +|Device registration |A device's P-256 public key is registered only within minutes of a sign-in, and with the password again when the account has no authenticator. |`money/Devices.java` +|Request signing |A payment is signed over the method, path, body hash, a nonce and a timestamp. A replayed nonce, an old timestamp or a wrong key is refused, and the nonce is recorded in its own transaction before any money moves. |`money/RequestSigning.java`, `shared/.../SignedRequest.java` +|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` +|Integrity |The check of the platform's attestation token. The sample's verifier is simulated; see below. |`money/IntegrityVerifier.java`, `money/SimulatedIntegrityVerifier.java` +|Limits |Per transfer, per day, and per new payee during its cooling-off period; crypto limits counted in dollars at the market's index price. |`money/Money.java`, `money/Payees.java` +|Allow-list |Coins leave only to a signed-in, signed, cooled-off address. |`exchange/CryptoWallets.java` +|Audit chain |Every security and money event is a row whose hash covers the row before it, appended under a lock on the chain's head; `/api/admin/audit/verify` walks it. |`audit/Audit.java` +|Ledger check |`/api/admin/ledger/verify` checks that every entry balances and every balance equals its postings. |`admin/AdminEndpoint.java` +|Administrators |No request makes one. Outside development, the only administrator comes from `bastion.admin.email` and `bastion.admin.password` in the environment. |`account/DemoAccounts.java` +|=== + +==== In the app + +[cols="1,2,1",options="header"] +|=== +|Feature |What it does |Where +|Network guard |Installed before the first request: no cleartext except to a development server on this machine, a host allow-list, and certificate pins, through `AppShield`. |`net/Guard.java`, `AppConfig.java` +|Sign-in |The password form and the second-factor step, then the authorization-code flow with PKCE; passkey sign-in. |`net/Session.java` +|Token storage |The tokens are kept in `SecureStorage`, the Keychain on iOS and the Keystore-backed store on Android. |`net/Session.java` +|Device key |A P-256 pair from `KeyGenerator.ecP256()`. The private half is sealed with AES-256-GCM in a `SecureEnvelope` under a wrapping key in `SecureStorage`; the server gets the public half. |`security/DeviceKey.java` +|Request signer |Signs the exact bytes the request sends, in the canonical form the server checks. |`security/RequestSigner.java` +|App lock |Locks on start with a stored session, after time in the background and after idle time; biometrics when the customer opts in, and a fresh sign-in otherwise. The unsealed key is dropped while locked. |`security/Lock.java`, `ui/LockForm.java` +|Screen protection |Secure windows on sensitive screens, tapjacking protection, and a clipboard that clears itself. |`security/Protect.java` +|Device integrity |Root and jailbreak signals, and the attestation token the server checks. |`security/DeviceKey.java`, `security/SecurityStatus.java` +|Security Center |Lists every protection above with its real state on this device and build. |`security/SecurityStatus.java`, `ui/SecurityCenterForm.java` +|=== + +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. + +=== Threat model + +Bastion is built against these, in this order of likelihood: + +* *Stolen or guessed passwords.* A second factor or a passkey, a lockout per account, rate limits per address, and a password minimum checked on the server. +* *A stolen session.* Access tokens last five minutes. A password change ends every other sign-in. Money leaving the account needs a signature from a registered device as well, so a token alone moves nothing out. +* *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. +* *A tampered or replayed request.* The signature covers the body and the path, and each nonce is accepted once within a short time window. +* *An attacker in the network path.* No cleartext, a host allow-list and certificate pins in the app; HSTS on the server. +* *A lost or stolen phone.* The app lock, the sealed device key, removal of the device from another sign-in, and secure windows that keep balances out of screenshots and the app switcher. +* *A rooted device or a repackaged app.* Root and jailbreak signals, platform attestation, and code hardening in a cloud build. None of these is proof; the server treats them as signals that decide whether a device is trusted. +* *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. +* *Races and retries.* Conditional balance updates, ordered locks and idempotency keys keep money exact under concurrent and repeated requests. + +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. + +=== 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 or in a local build. The Security Center reports each as inactive there rather than claiming it's on. + +[cols="1,2",options="header"] +|=== +|Build hint |What it turns on +|`cn1.hardenLevel=aggressive` |Code hardening: string encryption, control-flow obfuscation 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. +|`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. + +=== Tests + +[source,bash] +---- +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 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. + +=== 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. +* *Real custody of coins.* The coins are entries in the ledger, and a withdrawal moves them to a holding account. A product needs wallets on the real chains, with the keys in custody, deposit addresses per customer, confirmations, and the chain's fees. +* *A real integrity check.* `SimulatedIntegrityVerifier` believes the device. Production verifies the Play Integrity token with Google and the App Attest assertion with Apple, on the server. +* *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. +* *Sign-in in the browser.* The browser build loads and shows its sign-in form, but the app's sign-in uses a loopback redirect that a page can't follow. A browser product signs in through the server's own pages, with a redirect back to the app's origin and a CSRF token on every form. +* *Translations.* The app ships only English, and the server's refusals are English text. Every string is in `Bundle_en.properties`, so adding a language is a copy and a translation, and the server's refusals would need codes the app translates. +* *Device registration by passkey only.* A customer who signs in only with a passkey still gives a password to register a device. Recording how a sign-in was made, and accepting a fresh passkey sign-in in place of the password, removes that. +* *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..cd08d2245cb --- /dev/null +++ b/docs/developer-guide/Bastion-Users-Guide.asciidoc @@ -0,0 +1,118 @@ +[[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. + +Nothing in Bastion is real money. Deposits are a counter, cards spend nowhere, coins never touch a blockchain, and the exchange trades against a simulated market. The security is real: the passwords, codes, passkeys, signatures and audit chain work exactly as they would in production. + +=== 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] +---- +cd scripts/bastion +CN1_PROFILE=dev backend/server.sh run +---- + +The app then runs in the simulator from another terminal: + +[source,bash] +---- +cd scripts/bastion +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +---- + +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 money in a checking account +|`customer2@bastion.example` |A second customer, 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 + +*Open an account* on the sign-in screen walks through the steps a new customer takes: + +. *E-mail and password.* The password has to be at least twelve characters, and the server checks it, not only the app. +. *Authenticator app.* The app shows a QR code and a secret. Scanning the code with any authenticator app and typing the six digits it shows turns on the second factor. The secret can be copied instead; the app wipes it from the clipboard after a short while, so that it doesn't stay where other apps can read it. Bastion then shows ten recovery codes, once. Each opens the account once if the phone is lost; keep them somewhere other than the phone. +. *Passkey.* Where the device supports passkeys, one can be added now, so that later sign-ins need no password at all. +. *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. +. *Unlock with biometrics.* An option: the app can ask for a fingerprint or a face instead of signing in again whenever it locks. + +A new account starts with a dollar checking account. Others, in euros or as coin wallets, are opened from the dashboard. + +=== Signing in + +Signing in takes the e-mail and password, then a code from the authenticator app when the account has one. A passkey replaces both where one is set up. 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. + +The app locks itself after it has been in the background for a while, and after a few minutes without a touch. Locked, it shows nothing of the account and can't sign a payment until it's unlocked again, with biometrics when that's turned on and with the password when it's not. + +=== The dashboard + +The dashboard lists every account with its balance, and the total in dollars at the exchange's current prices. Tapping an account opens its history: every posting, newest first, with what it was for. + +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 + +*Transfer* moves money two ways: + +* *Between your own accounts*, in the same currency. +* *To a payee*: another Bastion customer, added by e-mail on the *Payees* screen. + +Every transfer carries a key that makes it happen once: a payment retried after a dropped connection isn't paid twice. The receipt shows the entry it made in the ledger. + +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. +* A new payee is in a cooling-off period for its first day, in which it can receive only a small amount. 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 a threshold is signed with the device key too. +* Every transfer is held to a limit per transfer and per day; one past either is refused. + +=== Cards + +*Cards* issues a card on a checking account, freezes and unfreezes it, and sets its spending limit. A frozen card is refused at once. The card number is on a sensitive screen. + +=== The exchange + +*Exchange* lists the markets, BTC-USD and ETH-USD, with their last price. A market opens its order book, its recent trades 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. + +=== Withdrawing 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. + +=== 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 device key is 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. + +It's honest by design. A protection the simulator 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, says that instead of claiming to be on. A development exemption in use, such as the unpinned connection to a server on this machine, is shown as a warning. + +=== Settings + +*Settings* changes the password (which ends every other sign-in), turns biometric unlock on and off, lists and removes passkeys and adds new ones, and lists the devices registered to the account, any of which can be removed. A removed device can no longer sign anything. + +=== The administrator + +An administrator signs in like a customer and sees *Admin* in the menu. It runs the two checks a bank's auditors ask for: + +* *Verify the ledger*: every entry sums to zero in each currency, and every balance equals the sum of its postings. +* *Verify the audit chain*: 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. + +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/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index e7a2a5ec371..67741b95c0f 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -155,6 +155,10 @@ include::Savor-Users-Guide.asciidoc[] include::Savor-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/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 271abf501c0..bf9fee82c62 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -813,3 +813,9 @@ Cinemachine # The sample application the Wayline chapters walk through. Savor Wayline +# The sample application the Bastion chapters walk through, and the units its ledger keeps coins in. +Bastion +satoshi +gwei +wei +cleartext diff --git a/scripts/bastion/README.adoc b/scripts/bastion/README.adoc new file mode 100644 index 00000000000..adabf649bdd --- /dev/null +++ b/scripts/bastion/README.adoc @@ -0,0 +1,114 @@ += 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 opens it in a headless browser: it loads from the server and shows + its sign-in form. Needs Node with Playwright and its Chromium (`npm ci` and + `npx playwright install chromium` in `scripts/`). Screenshots and logs go to + `target/web-e2e`. + +`.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/src/main/java/com/codenameone/examples/bastion/exchange/Exchange.java b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/exchange/Exchange.java index b144264b6da..691934c7d87 100644 --- 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 @@ -205,7 +205,7 @@ private OrderDto placeOnce(String owner, String key, NewOrderDto r) throws IOExc } if (buy) { long most = Markets.ceilDiv(exact(r.price(), r.quantity()), scale); - held = Math.addExact(most, fee(most)); + held = Markets.addExact(most, fee(most)); } else { held = r.quantity(); } @@ -264,12 +264,15 @@ private OrderDto placeOnce(String owner, String key, NewOrderDto r) throws IOExc /// 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 { - TradeOrder peek = id == null ? null : orders.findById(id); - if (peek == null || !peek.getOwner().equals(owner)) { + String market = id == null ? null : orders.marketOf(id, owner); + if (market == null) { throw new ResponseStatusException(404, "No such order"); } - Market m = lock(peek.getMarket()); - // Read again behind the lock: it may have filled while we waited. + 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 new ResponseStatusException(409, "That order is already " + o.getStatus()); @@ -367,9 +370,9 @@ private long affordable(TradeOrder o, long price, long available, long lot, long /// 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(Math.addExact(o.getNotional(), - Math.multiplyExact(price, quantity)), scale); - return Math.addExact(quote, fee(quote)); + 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; } @@ -380,8 +383,8 @@ private long cost(TradeOrder o, long price, long quantity, long scale) { 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 = Math.addExact(buy.getNotional(), product); - long sellNotional = Math.addExact(sell.getNotional(), product); + 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(); @@ -521,12 +524,12 @@ private void record(List events) throws IOException { /// The taker fee on `quote`, rounded up. long fee(long quote) { - return Markets.ceilDiv(Math.multiplyExact(quote, feeBps), 10000L); + return Markets.ceilDiv(Markets.multiplyExact(quote, feeBps), 10000L); } private static long exact(long a, long b) { try { - return Math.multiplyExact(a, b); + return Markets.multiplyExact(a, b); } catch (ArithmeticException tooLarge) { throw new ResponseStatusException(400, "That order is too large"); } @@ -534,7 +537,7 @@ private static long exact(long a, long b) { private static void add(Map sums, String account, long amount) { Long was = sums.get(account); - sums.put(account, Long.valueOf(Math.addExact(was == null ? 0L : was.longValue(), + sums.put(account, Long.valueOf(Markets.addExact(was == null ? 0L : was.longValue(), amount))); } 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 index 61ecfc349ca..771620d4b38 100644 --- 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 @@ -36,9 +36,9 @@ import com.codenameone.examples.bastion.repository.TradeOrderRepository; import java.io.IOException; -import java.security.SecureRandom; 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. @@ -66,7 +66,9 @@ public class MarketMaker { private final Ledger ledger; private final TradeOrderRepository orders; private final boolean enabled; - private final SecureRandom random = new SecureRandom(); + // 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(); public MarketMaker(Exchange exchange, Markets markets, Ledger ledger, TradeOrderRepository orders, Config config, 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 index 3f98423e65a..ce3735fd9ed 100644 --- 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 @@ -42,7 +42,7 @@ /// **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 [Math#multiplyExact], so a product too large for a `long` is refused +/// 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 { @@ -61,8 +61,8 @@ public Markets(MarketRepository markets, Ledger ledger, @PostConstruct public void seed() throws IOException { - for (String one : configured.split(",")) { - String[] f = one.trim().split(":"); + 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"); @@ -152,7 +152,7 @@ public long value(String asset, long amount, String quote) throws IOException { } Asset base = ledger.asset(asset); try { - return ceilDiv(Math.multiplyExact(m.getIndexPrice(), amount), scale(base)); + return ceilDiv(multiplyExact(m.getIndexPrice(), amount), scale(base)); } catch (ArithmeticException tooLarge) { return Long.MAX_VALUE; } @@ -162,11 +162,50 @@ public long value(String asset, long amount, String quote) throws IOException { static long scale(Asset asset) { long s = 1; for (int i = 0; i < asset.getDecimals(); i++) { - s = Math.multiplyExact(s, 10L); + 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; 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..cee291878cf --- /dev/null +++ b/scripts/bastion/backend/src/main/java/com/codenameone/examples/bastion/ledger/IsolationCheck.java @@ -0,0 +1,65 @@ +/* + * 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; + } + Map row = db.queryOne("SELECT @@transaction_isolation AS level", null); + Object level = 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)"); + } + } +} 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 index e5d48efb311..e7fd558cc4a 100644 --- 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 @@ -46,6 +46,13 @@ List asks(@Param("market") String market, @Param("side") String side 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, 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 index 0bb916109c8..8409ebdb3ce 100644 --- 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 @@ -38,7 +38,6 @@ import java.util.Collections; import java.util.List; import java.util.Random; -import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -75,7 +74,7 @@ class ExchangeTest { /// 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-" + UUID.randomUUID().toString().substring(0, 12) + "@bastion.example"; + 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"); @@ -114,7 +113,7 @@ private String status(OrderDto o) throws Exception { } private static String key() { - return UUID.randomUUID().toString(); + return TestKeys.random(); } private void booksBalance() throws Exception { 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 index 7616a85355c..190ee626448 100644 --- 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 @@ -19,10 +19,10 @@ 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.crypto.Der; import com.codename1.backend.security.webauthn.CredentialRecord; import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntity; import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntityRepository; @@ -39,14 +39,8 @@ import org.junit.jupiter.api.Test; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.MessageDigest; -import java.security.Signature; -import java.security.spec.ECGenParameterSpec; import java.util.List; import java.util.Map; -import java.util.UUID; import static com.codename1.backend.test.MockMvcRequestBuilders.delete; import static com.codename1.backend.test.MockMvcRequestBuilders.get; @@ -87,11 +81,11 @@ private final class Customer { final String name; String token; final String checking; - KeyPair key; + TestKeys key; String device; Customer(boolean rooted) throws Exception { - name = SignIn.register(mvc, "m" + UUID.randomUUID().toString().substring(0, 12) + 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")); @@ -99,9 +93,9 @@ private final class Customer { device = register(key, rooted); } - String register(KeyPair pair, boolean rooted) throws Exception { + String register(TestKeys pair, boolean rooted) throws Exception { MockResponse answer = send(json("/api/devices", "{\"publicKey\":\"" - + Base64Url.encode(pair.getPublic().getEncoded()) + "\",\"platform\":\"and\"," + + Base64Url.encode(pair.publicKey) + "\",\"platform\":\"and\"," + "\"rooted\":" + rooted + ",\"attested\":true,\"integrityToken\":\"t\"," + "\"password\":\"" + PASSWORD + "\"}")); assertEquals(200, answer.getStatus(), answer.getContentAsString()); @@ -144,7 +138,7 @@ String open(String asset, String kind) throws Exception { } /// POSTs `body` to `path`, signed by `pair` as device `device`. - MockResponse signed(String path, String body, KeyPair pair, long ts, String nonce, + 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)); @@ -158,15 +152,12 @@ MockResponse signed(String path, String body, String idempotencyKey) throws Exce return signed(path, body, key, System.currentTimeMillis(), nonce(), idempotencyKey); } - String signature(String method, String path, String body, KeyPair pair, long ts, + String signature(String method, String path, String body, TestKeys pair, long ts, String nonce) throws Exception { String text = method + "|" + path + "|" - + Ids.hex(MessageDigest.getInstance("SHA-256").digest(body.getBytes("UTF-8"))) + + Ids.hex(Crypto.sha256(body.getBytes("UTF-8"))) + "|" + nonce + "|" + ts; - Signature signer = Signature.getInstance("SHA256withECDSA"); - signer.initSign(pair.getPrivate()); - signer.update(text.getBytes("UTF-8")); - byte[] jose = Der.ecdsaDerToJose(signer.sign(), 32); + byte[] jose = pair.sign(text.getBytes("UTF-8")); return "v1;device=" + device + ";ts=" + ts + ";nonce=" + nonce + ";sig=" + Base64Url.encode(jose); } @@ -189,17 +180,15 @@ private static MockRequestBuilder json(String path, String body) { } private static String key() { - return UUID.randomUUID().toString(); + return TestKeys.random(); } private static String nonce() { - return UUID.randomUUID().toString().replace("-", ""); + return TestKeys.random(); } - private static KeyPair newKey() throws Exception { - KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); - generator.initialize(new ECGenParameterSpec("secp256r1")); - return generator.generateKeyPair(); + private static TestKeys newKey() { + return TestKeys.next(); } private static String internal(String from, String to, long amount) { @@ -211,9 +200,9 @@ private static String internal(String from, String to, long amount) { void listsAndRemovesOnlyTheCallersPasskeys() throws Exception { Customer c = new Customer(false); Customer other = new Customer(false); - byte[] handle = UUID.randomUUID().toString().getBytes("UTF-8"); + byte[] handle = TestKeys.random().getBytes("UTF-8"); passkeyHolders.save(new PublicKeyCredentialUserEntity(c.name, handle, c.name)); - byte[] credentialId = UUID.randomUUID().toString().getBytes("UTF-8"); + 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()); @@ -396,7 +385,7 @@ void aRootedDeviceKeepsMoneyAtHome() throws Exception { Customer b = new Customer(false); a.deposit(a.checking, 50000); String payee = a.addPayee(b); - KeyPair rooted = newKey(); + TestKeys rooted = newKey(); a.device = a.register(rooted, true); a.key = rooted; assertEquals(403, a.signed("/api/transfers", a.payBody(payee, 1000), key()).getStatus()); @@ -458,7 +447,7 @@ private String aged(String token, long minutes) throws Exception { @Test void addingADeviceNeedsAFreshSignInAndThePassword() throws Exception { Customer c = new Customer(false); - String key = Base64Url.encode(newKey().getPublic().getEncoded()); + String key = Base64Url.encode(newKey().publicKey); String noPassword = "{\"publicKey\":\"" + key + "\",\"platform\":\"and\"," + "\"rooted\":false,\"attested\":true,\"integrityToken\":\"t\"}"; assertEquals(403, c.send(json("/api/devices", noPassword)).getStatus()); 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 index f582fb14e89..83a69a1c0bc 100644 --- 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 @@ -21,7 +21,6 @@ import com.codenameone.examples.bastion.api.SignedRequest; import org.junit.jupiter.api.Test; -import java.security.MessageDigest; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -32,7 +31,7 @@ class SigningVectorTest { @Test void canonicalForm() throws Exception { String body = "{\"amount\":1500,\"memo\":\"rent\"}"; - byte[] digest = MessageDigest.getInstance("SHA-256").digest(body.getBytes("UTF-8")); + byte[] digest = com.codename1.backend.Crypto.sha256(body.getBytes("UTF-8")); assertEquals("POST|/api/accounts/a1/withdraw|" + "27faf49a231437b9d9308db1799c1ab06d8665c087da17478d59acbefa20a125|" + "AAECAwQFBgcICQoLDA0ODw|1767225600000", 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/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 index 117b0bc28d5..675760818a7 100644 --- 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 @@ -59,15 +59,24 @@ private Lock() { public static void install(Screens app) { screens = app; - if (idle == null) { - idle = UITimer.timer(5000, true, Lock::checkIdle); - } } - /// Called by every form the app shows: a touch anywhere is activity. + /// Called by every form the app shows: a touch anywhere is activity, and + /// the idle check moves to the form. + /// + /// A UITimer runs only while the form it is bound to is showing, so the + /// check is bound again to each new form rather than once at start -- + /// where, before the first form, there is nothing to bind it to (the + /// browser has no current form at that point at all). public static void watch(Form form) { form.addPointerPressedListener(evt -> touched()); form.addPointerDraggedListener(evt -> touched()); + if (idle == null) { + idle = new UITimer(Lock::checkIdle); + } else { + idle.cancel(); + } + idle.schedule(5000, true, form); } public static void touched() { 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 index 8feb592d63e..6c7dde58113 100644 --- 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 @@ -22,8 +22,8 @@ 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.Display; -import com.codename1.ui.util.UITimer; import com.codenameone.examples.bastion.AppConfig; /// Screen and input protection. @@ -71,7 +71,9 @@ public static boolean isSecureScreen() { public static void copy(final String text) { Display.getInstance().copyToClipboard(text); copied = text; - UITimer.timer((int) AppConfig.CLIPBOARD_CLEAR_MILLIS, false, () -> { + // CN.setTimeout, not a UITimer: a UITimer runs only while the form it + // is bound to is showing, and the user moves on after copying. + CN.setTimeout((int) AppConfig.CLIPBOARD_CLEAR_MILLIS, () -> { Object now = Display.getInstance().getPasteDataFromClipboard(); // Only what this app put there: never wipe something the user // copied from elsewhere since. diff --git a/scripts/bastion/e2e/LiveE2e.java b/scripts/bastion/e2e/LiveE2e.java new file mode 100644 index 00000000000..4c038ae0997 --- /dev/null +++ b/scripts/bastion/e2e/LiveE2e.java @@ -0,0 +1,542 @@ +/* + * 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"); + 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); + alice.expect("POST", "/api/transfers", pay, key, signature, 200); + alice.expect("POST", "/api/transfers", pay, id(), signature, 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"); + Map sell = alice.json(bob.call("POST", "/api/exchange/orders", + order("SELL", 6000000L, 100000L), id(), null), 200); + check("OPEN".equals(sell.get("status")), "the sell rests on the book: " + sell); + Map buy = alice.json(alice.call("POST", "/api/exchange/orders", + order("BUY", 6000000L, 100000L), id(), null), 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}"; + alice.expect("POST", withdraw, nowhere, id(), alice.signature("POST", withdraw, nowhere), + 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); + alice.expect("POST", withdraw, there, id(), alice.signature("POST", withdraw, there), + 409); + check(alice.balance(aliceWallet) == 100000L, "and nothing left the wallet"); + } + + 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; + + Customer(String name) throws Exception { + 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", PASSWORD)); + 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 { + secret = (String) json(call("POST", "/api/account/mfa/totp", "", null, null), 200) + .get("secret"); + Map confirmed = json(call("POST", "/api/account/mfa/totp/confirm", + "{\"code\":\"" + totp(secret, 0) + "\"}", 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) throws Exception { + long ts = System.currentTimeMillis(); + String nonce = id(); + String text = method + "|" + target + "|" + hex(MessageDigest.getInstance("SHA-256") + .digest(body.getBytes(StandardCharsets.UTF_8))) + "|" + 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)), 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. + 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/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..09de17d057d --- /dev/null +++ b/scripts/bastion/run-web-e2e.sh @@ -0,0 +1,109 @@ +#!/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] +# +# --native runs the compiled server; --prebuilt skips both builds and uses what +# `backend/server.sh build --web` left. +# +# 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. +# 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 +for arg in "$@"; do + case "$arg" in + --native) MODE="--native" ;; + --prebuilt) PREBUILT=1 ;; + *) 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" +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-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" 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/web-e2e.mjs b/scripts/bastion/web-e2e.mjs new file mode 100644 index 00000000000..6985445a6c4 --- /dev/null +++ b/scripts/bastion/web-e2e.mjs @@ -0,0 +1,253 @@ +// 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. With BASTION_USER and BASTION_PASSWORD it then signs in through that +// form and waits for the dashboard. +// 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 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 email = process.env.BASTION_USER || ''; +const password = process.env.BASTION_PASSWORD || ''; +// 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; +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'] +}); +// A browser reports where it is, and the restaurants listed are the ones that +// deliver there: this one is by the seeded restaurants, near Pier 39. +const page = await browser.newPage({ viewport: { width: 420, height: 860 } }); +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 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'), + 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() { + return (await components()).filter(c => c.role === 'textbox' && c.height > 40 && c.height < 120) + .sort((a, b) => a.y - b.y); +} + +async function click(component) { + 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 && 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(); + }); + await page.keyboard.type(text, { delay: 10 }); +} + +/// 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); +} + +/// 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]); + } +} + +let failed = null; +try { + say('opening ' + url); + 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(', ')); + } + } + + // 2. Signing in, when an account is given. + if (email) { + await signInAs(email); + await waitFor('the dashboard', stepSeconds, async () => + /Total balance|Accounts/.test(await screenText())); + await noErrorDialog(); + await page.screenshot({ path: path.join(out, '2-home.png') }); + say('signed in as ' + email); + } +} 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 29e5f1db808..6b4dd803ed9 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), and carry that header + # under MIT No Attribution (scripts/wayline/LICENSE, scripts/savor/LICENSE, + # scripts/bastion/LICENSE), and carry that header # or none. case "$candidate" in - scripts/wayline/*|scripts/savor/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; + scripts/wayline/*|scripts/savor/*|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 } From ab7f9bb37f44c9a9cbd27d433849ae75d9d53722 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:09:06 +0300 Subject: [PATCH 10/53] Bastion sample: offer it as an Initializr template Template.BASTION with its resources (synced from scripts/bastion), a preview from the web e2e sign-in screen, the selector and summary entries, its README section and brand colour, fixture and matrix checks, and check-template.sh run by bastion.yml. Migrations (.sql) are now rewritten as text; --brand-tint and --radius-small are set only where a template's theme declares them. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bastion.yml | 28 ++ scripts/bastion/check-template.sh | 81 ++++ .../com/codename1/initializr/Initializr.java | 7 +- .../initializr/model/GeneratorModel.java | 62 ++- .../codename1/initializr/model/Template.java | 11 +- .../common/src/main/resources/bastion-css.zip | Bin 0 -> 13284 bytes .../src/main/resources/bastion-modules.zip | Bin 0 -> 403917 bytes .../common/src/main/resources/bastion-pom.xml | 398 ++++++++++++++++++ .../src/main/resources/bastion-preview.jpg | Bin 0 -> 21263 bytes .../resources/bastion-settings.properties | 15 + .../common/src/main/resources/bastion-src.zip | Bin 0 -> 252856 bytes .../model/GeneratorModelMatrixTest.java | 30 +- scripts/sync-initializr-template.py | 6 +- scripts/tests/GenerateInitializr.java | 2 +- scripts/tests/generate-initializr-fixtures.py | 15 +- 15 files changed, 630 insertions(+), 25 deletions(-) create mode 100755 scripts/bastion/check-template.sh create mode 100644 scripts/initializr/common/src/main/resources/bastion-css.zip create mode 100644 scripts/initializr/common/src/main/resources/bastion-modules.zip create mode 100644 scripts/initializr/common/src/main/resources/bastion-pom.xml create mode 100644 scripts/initializr/common/src/main/resources/bastion-preview.jpg create mode 100644 scripts/initializr/common/src/main/resources/bastion-settings.properties create mode 100644 scripts/initializr/common/src/main/resources/bastion-src.zip diff --git a/.github/workflows/bastion.yml b/.github/workflows/bastion.yml index a0444635e84..553fd659dd5 100644 --- a/.github/workflows/bastion.yml +++ b/.github/workflows/bastion.yml @@ -59,6 +59,11 @@ on: - '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/**' - '.github/workflows/bastion.yml' push: branches: [ master ] @@ -72,6 +77,8 @@ on: - 'CodenameOne/src/com/codename1/orm/**' - 'CodenameOne/src/com/codename1/annotations/**' - 'scripts/ci/sample-server-tests-on-database.sh' + - 'scripts/sync-initializr-template.py' + - 'scripts/initializr/common/src/**' - '.github/workflows/bastion.yml' workflow_dispatch: @@ -87,6 +94,18 @@ permissions: 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 @@ -297,6 +316,15 @@ jobs: 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. 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/initializr/common/src/main/java/com/codename1/initializr/Initializr.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java index 58cae813aae..bc416b6ddbd 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 @@ -437,15 +437,15 @@ private Container createEssentialsPanel(TextField appNameField, TextField packag /// the food-delivery app with its server. private Container createLanguageSelector(Template[] selectedTemplate, Runnable onSelectionChanged) { boolean fullStack = GeneratorModel.isTemplateOffered(Template.WAYLINE); - Container selector = new Container(new GridLayout(fullStack ? 4 : 1, fullStack ? 1 : 2)); + Container selector = new Container(new GridLayout(fullStack ? 5 : 1, fullStack ? 1 : 2)); selector.setUIID("InitializrChoicesGrid"); ButtonGroup group = new ButtonGroup(); Template[] languages = fullStack - ? new Template[] {Template.BAREBONES, Template.KOTLIN, Template.WAYLINE, Template.SAVOR} + ? new Template[] {Template.BAREBONES, Template.KOTLIN, Template.WAYLINE, Template.SAVOR, Template.BASTION} : new Template[] {Template.BAREBONES, Template.KOTLIN}; String[] labels = fullStack ? new String[] {"Empty app, Java", "Empty app, Kotlin", "Ride-hailing app with its server, Java", - "Food delivery app with its server, Java"} + "Food delivery app with its server, Java", "Banking app with its server, Java"} : new String[] {"Java", "Kotlin"}; for (int i = 0; i < languages.length; i++) { final Template template = languages[i]; @@ -1176,6 +1176,7 @@ private String createSummary(String appName, String packageName, Template templa return "App " + safeApp + "\n" + "Package " + safePackage + "\n" + (template == Template.SAVOR ? "Template FOOD-DELIVERY APP + SERVER\n" + : template == Template.BASTION ? "Template BANKING APP + SERVER\n" : template.isFullStack() ? "Template RIDE-HAILING APP + SERVER\n" : "") + "Language " + (template.IS_KOTLIN ? "KOTLIN" : "JAVA") + "\n" + "IDE " + ide.name() + "\n" 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 ef4f0447d45..d92be63427b 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 @@ -86,10 +86,16 @@ 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 brand colour `template` ships with: the one its icon is painted with /// when the developer keeps the template's own scheme. public static int brandOf(Template template) { - return template == Template.SAVOR ? SAVOR_BRAND : FULL_STACK_BRAND; + if (template == Template.SAVOR) { + return SAVOR_BRAND; + } + return template == Template.BASTION ? BASTION_BRAND : FULL_STACK_BRAND; } /// Whether `template` can be generated at [CN1_PLUGIN_VERSION]: the UI offers @@ -1793,12 +1799,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; } @@ -1816,6 +1824,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; @@ -1939,7 +1954,9 @@ private String buildReadmeMarkdown() { .append("`./mvnw -pl backend -Dcodename1.platform=backend cn1:backend` and package it as a native ") .append("binary with `./mvnw -pl backend -Dcodename1.platform=backend cn1:backend-package`.\n\n"); } - if (template.isFullStack()) { + if (template == Template.BASTION) { + appendBankSection(out, toLowerCaseInvariant(appName)); + } else if (template.isFullStack()) { boolean food = template == Template.SAVOR; String lower = toLowerCaseInvariant(appName); out.append("## The app and its server\n\n") @@ -2028,6 +2045,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") @@ -2638,6 +2688,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(".adoc") || path.endsWith(".bat") 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 b432e9a8c64..453d81fb1ad 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 @@ -60,7 +60,16 @@ public enum Template { "com.codenameone.examples.savor", "Savor", "/savor-preview.jpg", "The food-delivery template is an app, a server and the contract module they share; " + "an Initializr Gradle project is a single project.", - new String[0], "/savor-modules.zip", "/savor-settings.properties"); + new String[0], "/savor-modules.zip", "/savor-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 0000000000000000000000000000000000000000..66446d82c1964e8b0e2c18a223a3dac943c92485 GIT binary patch literal 13284 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