diff --git a/App integrity.md b/App integrity.md new file mode 100644 index 0000000..2c6c7ec --- /dev/null +++ b/App integrity.md @@ -0,0 +1,21 @@ +# App integrity from apple and google. + +- We need to start implementing app integrity in our app to prevent spam attacked on our server with malicious key pairs being generated and destroy our database. +- We need to make sure requests are coming from our app only. + +### Apple +For client side code, we can use this library i built in the past: +https://raw.githubusercontent.com/niteshbalusu11/react-native-secure-enclave-operations/refs/heads/master/README.md + +It also has server side code, but it's written in Nodejs, obviously we need a rust version for our app. + +### Google +For client side code, we can use this library I built in the past: +https://raw.githubusercontent.com/niteshbalusu11/react-native-secure-enclave-operations/refs/heads/master/README.md + +It also has server side code, but it's written in Node.js, obviously we need a Rust version for our app. + + +- The goal is to have this API as part of our sign up flow. +- So we do an integrity check on the first time a user creates an account and registers their device. +- We don't block the user if the integrity check fails but we capture it in our database so we can look into it later. diff --git a/Cargo.lock b/Cargo.lock index 8c79b91..ba301b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,6 +277,25 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "appattest-rs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90de6aa2a5a74151a9f92fe846338c6d1c5fbc5886d5d6753ff4ae029f4bc317" +dependencies = [ + "base64 0.22.1", + "byteorder", + "ciborium", + "openssl", + "p256 0.13.2", + "reqwest", + "serde", + "serde_cbor", + "serde_json", + "sha2", + "x509-parser", +] + [[package]] name = "ark-lib" version = "0.1.0-beta.2" @@ -301,6 +320,45 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "async-compression" version = "0.4.34" @@ -555,7 +613,7 @@ dependencies = [ "hmac", "http 0.2.12", "http 1.3.1", - "p256", + "p256 0.11.1", "percent-encoding", "ring", "sha2", @@ -879,6 +937,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base58ck" version = "0.1.0" @@ -1141,6 +1205,33 @@ dependencies = [ "phf", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half 2.7.1", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -1380,6 +1471,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.4.9" @@ -1398,8 +1495,10 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ + "generic-array", "rand_core 0.6.4", "subtle", + "zeroize", ] [[package]] @@ -1576,6 +1675,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.4.0" @@ -1719,11 +1832,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" dependencies = [ "der 0.6.1", - "elliptic-curve", - "rfc6979", + "elliptic-curve 0.12.3", + "rfc6979 0.3.1", "signature 1.6.4", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "either" version = "1.15.0" @@ -1739,16 +1866,36 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" dependencies = [ - "base16ct", + "base16ct 0.1.1", "crypto-bigint 0.4.9", "der 0.6.1", "digest", - "ff", + "ff 0.12.1", "generic-array", - "group", + "group 0.12.1", "pkcs8 0.9.0", "rand_core 0.6.4", - "sec1", + "sec1 0.3.0", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest", + "ff 0.13.1", + "generic-array", + "group 0.13.0", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1 0.7.3", "subtle", "zeroize", ] @@ -1852,6 +1999,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "findshlibs" version = "0.10.2" @@ -2064,6 +2221,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2166,7 +2324,18 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" dependencies = [ - "ff", + "ff 0.12.1", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] @@ -2229,6 +2398,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3240,6 +3426,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3320,8 +3515,20 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.14.8", + "elliptic-curve 0.12.3", + "sha2", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder", "sha2", ] @@ -3534,6 +3741,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -3918,6 +4134,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3946,6 +4172,7 @@ dependencies = [ "pkcs1", "pkcs8 0.10.2", "rand_core 0.6.4", + "sha2", "signature 2.2.0", "spki 0.7.3", "subtle", @@ -3973,6 +4200,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -4166,7 +4402,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" dependencies = [ - "base16ct", + "base16ct 0.1.1", "der 0.6.1", "generic-array", "pkcs8 0.9.0", @@ -4174,6 +4410,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -4416,6 +4666,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half 1.8.3", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4516,13 +4776,16 @@ name = "server" version = "0.1.0" dependencies = [ "anyhow", + "appattest-rs", "async-trait", "aws-config", "aws-sdk-s3", "axum", "bark-server-rpc", + "base64 0.22.1", "bitcoin", "chrono", + "ciborium", "clap", "deadpool-redis", "dotenvy", @@ -4538,10 +4801,12 @@ dependencies = [ "redis", "regex", "reqwest", + "rsa", "semver", "sentry", "serde", "serde_json", + "sha2", "sqlx", "thiserror 2.0.17", "tokio", @@ -6373,6 +6638,23 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xmlparser" version = "0.13.6" diff --git a/bun.lock b/bun.lock index f3bf9e8..e1e2398 100644 --- a/bun.lock +++ b/bun.lock @@ -70,6 +70,7 @@ "react-native-reanimated": "~4.1.5", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.16.0", + "react-native-secure-enclave-operations": "^0.0.9", "react-native-share": "^12.2.1", "react-native-svg": "15.12.1", "react-native-uuid": "^2.0.3", @@ -1747,6 +1748,8 @@ "react-native-screens": ["react-native-screens@4.16.0", "", { "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q=="], + "react-native-secure-enclave-operations": ["react-native-secure-enclave-operations@0.0.9", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.31.10" } }, "sha512-xgI/3Di0hJ3Zj+nbRViRMk+TnX621agOCE4dYQaC5NEriFhJKiDJK8VlSGVqV0g1t+1v5bSOfqk6qKFZ9YXzOQ=="], + "react-native-share": ["react-native-share@12.2.1", "", {}, "sha512-DPfvqQMbbLK4ykPkqYarby5AXdgFsbefOhsQHkOrDeAIixWzeI4oe/WvI7AoYmlQxM4Ys2DcBPBWDYQ6gn5xYA=="], "react-native-svg": ["react-native-svg@15.12.1", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g=="], diff --git a/client/ios/Podfile.lock b/client/ios/Podfile.lock index e9e5016..060971d 100644 --- a/client/ios/Podfile.lock +++ b/client/ios/Podfile.lock @@ -2569,6 +2569,29 @@ PODS: - SDWebImage/Core (5.21.3) - SDWebImageSVGCoder (1.8.0): - SDWebImage/Core (~> 5.6) + - SecureEnclaveOperations (0.0.9): + - hermes-engine + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - Sentry/HybridSDK (8.56.1) - SwiftUIIntrospect (1.3.0) - UMAppLoader (6.0.7) @@ -2703,6 +2726,7 @@ DEPENDENCIES: - RNWorklets (from `../../node_modules/react-native-worklets`) - SDWebImage - SDWebImageSVGCoder + - SecureEnclaveOperations (from `../../node_modules/react-native-secure-enclave-operations`) - UMAppLoader (from `../../node_modules/unimodules-app-loader/ios`) - VisionCamera (from `../../node_modules/react-native-vision-camera`) - Yoga (from `../../node_modules/react-native/ReactCommon/yoga`) @@ -2940,6 +2964,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/react-native-svg" RNWorklets: :path: "../../node_modules/react-native-worklets" + SecureEnclaveOperations: + :path: "../../node_modules/react-native-secure-enclave-operations" UMAppLoader: :path: "../../node_modules/unimodules-app-loader/ios" VisionCamera: @@ -3062,6 +3088,7 @@ SPEC CHECKSUMS: RNWorklets: 0138846284eddd6b0bddba8eb9d6cf60de3a8f2f SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a SDWebImageSVGCoder: 8e10c8f6cc879c7dfb317b284e13dd589379f01c + SecureEnclaveOperations: b4b82653ac4761ef5a4e08636023275e0ac30eb9 Sentry: b3ec44d01708fce73f99b544beb57e890eca4406 SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d UMAppLoader: e1234c45d2b7da239e9e90fc4bbeacee12afd5b6 diff --git a/client/package.json b/client/package.json index 2579da3..04c50da 100644 --- a/client/package.json +++ b/client/package.json @@ -107,6 +107,7 @@ "react-native-reanimated": "~4.1.5", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.16.0", + "react-native-secure-enclave-operations": "^0.0.9", "react-native-share": "^12.2.1", "react-native-svg": "15.12.1", "react-native-uuid": "^2.0.3", diff --git a/client/src/hooks/useAppAttestation.ts b/client/src/hooks/useAppAttestation.ts new file mode 100644 index 0000000..0f16cf0 --- /dev/null +++ b/client/src/hooks/useAppAttestation.ts @@ -0,0 +1,108 @@ +import { Platform } from "react-native"; +import { Result, ok } from "neverthrow"; +import { + isHardwareBackedKeyGenerationSupportedIos, + generateKeyIos, + attestKeyIos, + isPlayServicesAvailableAndroid, + prepareIntegrityTokenAndroid, + requestIntegrityTokenAndroid, +} from "react-native-secure-enclave-operations"; +import { getK1 } from "~/lib/api"; +import logger from "~/lib/log"; +import { IosAttestationPayload, AndroidAttestationPayload } from "~/types/serverTypes"; +import Constants from "expo-constants"; + +const log = logger("useAppAttestation"); + +export type AttestationResult = { + ios_attestation: IosAttestationPayload | null; + android_attestation: AndroidAttestationPayload | null; +}; + +const GOOGLE_CLOUD_PROJECT_NUMBER = Constants.expoConfig?.extra?.googleCloudProjectNumber as + | string + | undefined; + +export const performIosAttestation = async (): Promise => { + try { + const isSupported = await isHardwareBackedKeyGenerationSupportedIos(); + if (!isSupported) { + log.w("Device does not support hardware-backed key generation"); + return null; + } + + const keyId = await generateKeyIos(); + log.d("Generated iOS attestation key"); + + const k1Result = await getK1(); + if (k1Result.isErr()) { + log.w("Failed to get challenge for iOS attestation", [k1Result.error]); + return null; + } + const challenge = k1Result.value; + + const attestation = await attestKeyIos(keyId, challenge); + log.d("Successfully attested iOS key"); + + return { + attestation, + challenge, + key_id: keyId, + }; + } catch (error) { + log.w("iOS attestation failed", [error]); + return null; + } +}; + +export const performAndroidAttestation = async (): Promise => { + try { + if (!GOOGLE_CLOUD_PROJECT_NUMBER) { + log.w("Google Cloud Project Number not configured, skipping Android attestation"); + return null; + } + + const isAvailable = await isPlayServicesAvailableAndroid(); + if (!isAvailable) { + log.w("Google Play Services not available"); + return null; + } + + await prepareIntegrityTokenAndroid(GOOGLE_CLOUD_PROJECT_NUMBER); + log.d("Prepared Android integrity token provider"); + + const k1Result = await getK1(); + if (k1Result.isErr()) { + log.w("Failed to get challenge for Android attestation", [k1Result.error]); + return null; + } + const challenge = k1Result.value; + + const integrityToken = await requestIntegrityTokenAndroid(challenge); + log.d("Successfully obtained Android integrity token"); + + return { + integrity_token: integrityToken, + challenge, + }; + } catch (error) { + log.w("Android attestation failed", [error]); + return null; + } +}; + +export const performAttestation = async (): Promise> => { + if (Platform.OS === "ios") { + const ios_attestation = await performIosAttestation(); + return ok({ ios_attestation, android_attestation: null }); + } + + if (Platform.OS === "android") { + const android_attestation = await performAndroidAttestation(); + return ok({ ios_attestation: null, android_attestation }); + } + + log.d("Skipping attestation on unsupported platform"); + return ok({ ios_attestation: null, android_attestation: null }); +}; diff --git a/client/src/lib/server.ts b/client/src/lib/server.ts index c67c2f8..023db44 100644 --- a/client/src/lib/server.ts +++ b/client/src/lib/server.ts @@ -6,6 +6,7 @@ import { type Result, err } from "neverthrow"; import { RegisterResponse } from "~/types/serverTypes"; import Constants from "expo-constants"; import { peakAddress } from "~/lib/paymentsApi"; +import { performAttestation } from "~/hooks/useAppAttestation"; const log = logger("server"); @@ -21,6 +22,19 @@ export const performServerRegistration = async ( } const ark_address = addressResult.value.address; + // Attempt attestation (fails silently on error or unsupported devices) + const attestationResult = await performAttestation(); + const { ios_attestation, android_attestation } = attestationResult.isOk() + ? attestationResult.value + : { ios_attestation: null, android_attestation: null }; + + if (ios_attestation) { + log.d("Including iOS attestation in registration"); + } + if (android_attestation) { + log.d("Including Android attestation in registration"); + } + // Register with server and pass user device information. const result = await registerWithServer({ device_info: { @@ -32,6 +46,8 @@ export const performServerRegistration = async ( }, ln_address, ark_address, + ios_attestation, + android_attestation, }); if (result.isErr()) { diff --git a/client/src/types/serverTypes.ts b/client/src/types/serverTypes.ts index 2b73c43..93fc55d 100644 --- a/client/src/types/serverTypes.ts +++ b/client/src/types/serverTypes.ts @@ -1,5 +1,18 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Android Play Integrity payload sent during registration + */ +export type AndroidAttestationPayload = { +/** + * The integrity token from requestIntegrityTokenAndroid() + */ +integrity_token: string, +/** + * The challenge/nonce used for attestation (hashed request data) + */ +challenge: string, }; + export type AppVersionCheckPayload = { client_version: string, }; export type AppVersionInfo = { minimum_required_version: string, update_required: boolean, }; @@ -38,6 +51,23 @@ export type HeartbeatNotification = { k1: string, notification_id: string, }; export type HeartbeatResponsePayload = { notification_id: string, }; +/** + * iOS App Attestation payload sent during registration + */ +export type IosAttestationPayload = { +/** + * Base64-encoded attestation object from attestKeyIos() + */ +attestation: string, +/** + * The challenge/nonce used for attestation + */ +challenge: string, +/** + * Key identifier from generateKeyIos() + */ +key_id: string, }; + export type LightningInvoiceRequestNotification = { k1: string, transaction_id: string, amount: number, }; export type MaintenanceNotification = { k1: string, }; @@ -67,7 +97,15 @@ device_info: DeviceInfo | null, /** * Optional Ark address */ -ark_address: string | null, }; +ark_address: string | null, +/** + * Optional iOS attestation data + */ +ios_attestation: IosAttestationPayload | null, +/** + * Optional Android attestation data + */ +android_attestation: AndroidAttestationPayload | null, }; /** * Defines the payload for registering a push notification token. diff --git a/scripts/docker-compose.yml b/scripts/docker-compose.yml index 1278288..b1097d1 100644 --- a/scripts/docker-compose.yml +++ b/scripts/docker-compose.yml @@ -73,7 +73,7 @@ services: condition: service_healthy captaind: - image: niteshbalusu/captaind:nightly-2025-11-21 + image: niteshbalusu/captaind:nightly-2025-12-10 volumes: - captaind:/data/captaind - ./captaind.toml:/data/captaind/captaind.toml @@ -90,7 +90,7 @@ services: condition: service_healthy bark: - image: niteshbalusu/bark:nightly-2025-11-21 + image: niteshbalusu/bark:nightly-2025-12-10 volumes: - bark:/root depends_on: diff --git a/server/Cargo.toml b/server/Cargo.toml index d689c54..8288ec9 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -17,6 +17,11 @@ serde = { version = "1.0.225", features = ["derive"] } chrono = { version = "0.4.42", features = ["serde"] } hex = "0.4.3" rand = "0.9.2" +appattest-rs = "0.1.0" +base64 = "0.22" +ciborium = "0.2" +rsa = { version = "0.9", features = ["sha2"] } +sha2 = "0.10" bitcoin = "0.32.7" thiserror = "2.0.17" tokio-cron-scheduler = { version = "0.15.1", features = ["english"] } diff --git a/server/migrations/0003_add_device_attestations.sql b/server/migrations/0003_add_device_attestations.sql new file mode 100644 index 0000000..852f13d --- /dev/null +++ b/server/migrations/0003_add_device_attestations.sql @@ -0,0 +1,20 @@ +-- Device attestations for iOS App Attestation and Android Play Integrity + +CREATE TABLE device_attestations ( + id BIGSERIAL PRIMARY KEY, + pubkey TEXT NOT NULL REFERENCES users(pubkey) ON DELETE CASCADE, + platform TEXT NOT NULL, -- 'ios' or 'android' + key_id TEXT NOT NULL, + public_key TEXT, -- Base64-encoded public key bytes + receipt BYTEA, -- iOS attestation receipt for future fraud checks + environment TEXT NOT NULL, -- 'production' or 'development' + attestation_passed BOOLEAN NOT NULL DEFAULT FALSE, + failure_reason TEXT, -- If attestation_passed is false, why + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (pubkey, platform) +); + +CREATE INDEX idx_device_attestations_pubkey ON device_attestations(pubkey); +CREATE INDEX idx_device_attestations_platform ON device_attestations(platform); +CREATE INDEX idx_device_attestations_attestation_passed ON device_attestations(attestation_passed); diff --git a/server/src/attestation/android.rs b/server/src/attestation/android.rs new file mode 100644 index 0000000..ee44a48 --- /dev/null +++ b/server/src/attestation/android.rs @@ -0,0 +1,289 @@ +use anyhow::{Context, Result, anyhow}; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use rsa::pkcs8::DecodePrivateKey; +use rsa::signature::SignatureEncoding; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; +const PLAY_INTEGRITY_API_BASE: &str = "https://playintegrity.googleapis.com/v1"; +const PLAY_INTEGRITY_SCOPE: &str = "https://www.googleapis.com/auth/playintegrity"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceAccountCredentials { + pub client_email: String, + pub private_key: String, + pub project_id: Option, +} + +#[derive(Debug, Clone)] +pub struct AndroidAttestationResult { + pub device_recognition_verdict: Vec, + pub environment: String, +} + +pub struct AndroidAttestationParams<'a> { + pub integrity_token: &'a str, + pub package_name: &'a str, + pub expected_nonce: Option<&'a str>, + pub service_account_json: &'a str, +} + +#[derive(Debug, Serialize)] +struct JwtClaims { + iss: String, + scope: String, + aud: String, + iat: u64, + exp: u64, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, +} + +#[derive(Debug, Serialize)] +struct DecodeIntegrityTokenRequest { + #[serde(rename = "integrityToken")] + integrity_token: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DecodeIntegrityTokenResponse { + token_payload_external: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenPayload { + request_details: Option, + app_integrity: Option, + device_integrity: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RequestDetails { + nonce: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AppIntegrity { + package_name: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeviceIntegrity { + device_recognition_verdict: Option>, +} + +pub async fn verify_android_integrity( + params: AndroidAttestationParams<'_>, +) -> Result { + let credentials: ServiceAccountCredentials = serde_json::from_str(params.service_account_json) + .context("Failed to parse service account JSON")?; + + let access_token = get_access_token(&credentials).await?; + + let response = + decode_integrity_token(&access_token, params.package_name, params.integrity_token).await?; + + let payload = response + .token_payload_external + .ok_or_else(|| anyhow!("No token payload in response"))?; + + if let Some(expected_nonce) = params.expected_nonce + && let Some(ref request_details) = payload.request_details + && let Some(ref nonce) = request_details.nonce + && nonce != expected_nonce + { + return Err(anyhow!( + "Nonce mismatch: expected '{}', got '{}'", + expected_nonce, + nonce + )); + } + + if let Some(ref app_integrity) = payload.app_integrity + && let Some(ref pkg_name) = app_integrity.package_name + && pkg_name != params.package_name + { + return Err(anyhow!( + "Package name mismatch: expected '{}', got '{}'", + params.package_name, + pkg_name + )); + } + + let device_verdict = payload + .device_integrity + .and_then(|d| d.device_recognition_verdict) + .unwrap_or_default(); + + let environment = determine_environment(&device_verdict); + + Ok(AndroidAttestationResult { + device_recognition_verdict: device_verdict, + environment, + }) +} + +fn determine_environment(device_verdict: &[String]) -> String { + if device_verdict.contains(&"MEETS_STRONG_INTEGRITY".to_string()) + || device_verdict.contains(&"MEETS_DEVICE_INTEGRITY".to_string()) + { + "production".to_string() + } else if device_verdict.contains(&"MEETS_BASIC_INTEGRITY".to_string()) { + "basic".to_string() + } else if device_verdict.contains(&"MEETS_VIRTUAL_INTEGRITY".to_string()) { + "emulator".to_string() + } else { + "unknown".to_string() + } +} + +async fn get_access_token(credentials: &ServiceAccountCredentials) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System time before UNIX epoch")? + .as_secs(); + + let claims = JwtClaims { + iss: credentials.client_email.clone(), + scope: PLAY_INTEGRITY_SCOPE.to_string(), + aud: GOOGLE_TOKEN_URL.to_string(), + iat: now, + exp: now + 3600, + }; + + let jwt = create_signed_jwt(&claims, &credentials.private_key)?; + + let client = reqwest::Client::new(); + let response = client + .post(GOOGLE_TOKEN_URL) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + ("assertion", &jwt), + ]) + .send() + .await + .context("Failed to request access token")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!("Failed to get access token: {} - {}", status, body)); + } + + let token_response: TokenResponse = response + .json() + .await + .context("Failed to parse token response")?; + + Ok(token_response.access_token) +} + +fn create_signed_jwt(claims: &JwtClaims, private_key_pem: &str) -> Result { + let header = serde_json::json!({ + "alg": "RS256", + "typ": "JWT" + }); + + let header_b64 = BASE64.encode(serde_json::to_string(&header)?); + let claims_b64 = BASE64.encode(serde_json::to_string(claims)?); + + let signing_input = format!("{}.{}", header_b64, claims_b64); + + let key = rsa::RsaPrivateKey::from_pkcs8_pem(private_key_pem) + .context("Failed to parse RSA private key")?; + + use rsa::pkcs1v15::SigningKey; + use rsa::signature::Signer; + use sha2::Sha256; + + let signing_key = SigningKey::::new(key); + let signature = signing_key.sign(signing_input.as_bytes()); + let signature_b64 = BASE64.encode(signature.to_bytes()); + + Ok(format!("{}.{}", signing_input, signature_b64)) +} + +async fn decode_integrity_token( + access_token: &str, + package_name: &str, + integrity_token: &str, +) -> Result { + let full_url = format!( + "{}/{}:decodeIntegrityToken", + PLAY_INTEGRITY_API_BASE, package_name + ); + + let request_body = DecodeIntegrityTokenRequest { + integrity_token: integrity_token.to_string(), + }; + + let client = reqwest::Client::new(); + let response = client + .post(&full_url) + .bearer_auth(access_token) + .json(&request_body) + .send() + .await + .context("Failed to call Play Integrity API")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!("Play Integrity API error: {} - {}", status, body)); + } + + let decoded: DecodeIntegrityTokenResponse = response + .json() + .await + .context("Failed to parse Play Integrity response")?; + + Ok(decoded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_determine_environment() { + assert_eq!( + determine_environment(&["MEETS_STRONG_INTEGRITY".to_string()]), + "production" + ); + assert_eq!( + determine_environment(&["MEETS_DEVICE_INTEGRITY".to_string()]), + "production" + ); + assert_eq!( + determine_environment(&["MEETS_BASIC_INTEGRITY".to_string()]), + "basic" + ); + assert_eq!( + determine_environment(&["MEETS_VIRTUAL_INTEGRITY".to_string()]), + "emulator" + ); + assert_eq!(determine_environment(&[]), "unknown"); + } + + #[test] + fn test_parse_service_account_json() { + let json = r#"{ + "client_email": "test@project.iam.gserviceaccount.com", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + "project_id": "test-project" + }"#; + + let creds: ServiceAccountCredentials = serde_json::from_str(json).unwrap(); + assert_eq!(creds.client_email, "test@project.iam.gserviceaccount.com"); + assert_eq!(creds.project_id, Some("test-project".to_string())); + } +} diff --git a/server/src/attestation/ios.rs b/server/src/attestation/ios.rs new file mode 100644 index 0000000..e7ade6f --- /dev/null +++ b/server/src/attestation/ios.rs @@ -0,0 +1,96 @@ +use anyhow::{Context, Result, anyhow}; +use appattest_rs::attestation::Attestation; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; + +#[derive(Debug, Clone)] +pub struct IosAttestationResult { + pub public_key_bytes: Vec, + pub receipt: Vec, + pub environment: String, +} + +pub struct IosAttestationParams<'a> { + pub attestation_base64: &'a str, + pub challenge: &'a str, + pub key_id: &'a str, + pub bundle_identifier: &'a str, + pub team_identifier: &'a str, + pub allow_development: bool, +} + +pub fn verify_ios_attestation(params: IosAttestationParams) -> Result { + let app_id = format!("{}.{}", params.team_identifier, params.bundle_identifier); + + let attestation = Attestation::from_base64(params.attestation_base64) + .map_err(|e| anyhow!("Failed to parse attestation: {}", e))?; + + let environment = detect_environment(params.attestation_base64)?; + + if environment == "development" && !params.allow_development { + return Err(anyhow!("Development environment attestation not allowed")); + } + + let (public_key_bytes, receipt) = attestation + .verify(params.challenge, &app_id, params.key_id) + .map_err(|e| anyhow!("Attestation verification failed: {}", e))?; + + Ok(IosAttestationResult { + public_key_bytes, + receipt, + environment, + }) +} + +fn detect_environment(attestation_base64: &str) -> Result { + let attestation_bytes = BASE64 + .decode(attestation_base64) + .context("Failed to decode attestation from base64")?; + + let decoded: ciborium::Value = ciborium::from_reader(&attestation_bytes[..]) + .context("Failed to decode CBOR attestation")?; + + let map = decoded + .as_map() + .ok_or_else(|| anyhow!("Attestation is not a CBOR map"))?; + + let auth_data = map + .iter() + .find(|(k, _)| k.as_text() == Some("authData")) + .and_then(|(_, v)| v.as_bytes()) + .ok_or_else(|| anyhow!("authData not found or not bytes"))?; + + if auth_data.len() < 53 { + return Err(anyhow!("authData too short for aaguid")); + } + + let aaguid = &auth_data[37..53]; + + const AAGUID_DEVELOPMENT: &[u8] = b"appattestdevelop"; + const AAGUID_PRODUCTION: &[u8] = &[ + b'a', b'p', b'p', b'a', b't', b't', b'e', b's', b't', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, + ]; + + if aaguid == AAGUID_PRODUCTION { + Ok("production".to_string()) + } else if aaguid == AAGUID_DEVELOPMENT { + Ok("development".to_string()) + } else { + Err(anyhow!("Invalid aaguid in attestation")) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn test_aaguid_constants() { + const AAGUID_DEVELOPMENT: &[u8] = b"appattestdevelop"; + const AAGUID_PRODUCTION: &[u8] = &[ + b'a', b'p', b'p', b'a', b't', b't', b'e', b's', b't', 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]; + + assert_eq!(AAGUID_DEVELOPMENT.len(), 16); + assert_eq!(AAGUID_PRODUCTION.len(), 16); + } +} diff --git a/server/src/attestation/mod.rs b/server/src/attestation/mod.rs new file mode 100644 index 0000000..cce3329 --- /dev/null +++ b/server/src/attestation/mod.rs @@ -0,0 +1,5 @@ +pub mod android; +pub mod ios; + +pub use android::{AndroidAttestationParams, verify_android_integrity}; +pub use ios::{IosAttestationParams, verify_ios_attestation}; diff --git a/server/src/config.rs b/server/src/config.rs index 2160315..5df6166 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -32,6 +32,12 @@ pub struct Config { pub minimum_app_version: String, pub redis_url: String, pub ntfy_auth_token: String, + // App Attestation + pub apple_team_identifier: Option, + pub apple_bundle_identifier: Option, + pub android_package_name: Option, + pub google_service_account_json: Option, + pub allow_development_attestation: bool, } impl Config { @@ -80,6 +86,13 @@ impl Config { .unwrap_or_else(|_| "0.0.1".to_string()), redis_url: std::env::var("REDIS_URL").unwrap_or_else(|_| default_redis_url()), ntfy_auth_token: std::env::var("NTFY_AUTH_TOKEN").unwrap_or_default(), + apple_team_identifier: std::env::var("APPLE_TEAM_IDENTIFIER").ok(), + apple_bundle_identifier: std::env::var("APPLE_BUNDLE_IDENTIFIER").ok(), + android_package_name: std::env::var("ANDROID_PACKAGE_NAME").ok(), + google_service_account_json: std::env::var("GOOGLE_SERVICE_ACCOUNT_JSON").ok(), + allow_development_attestation: std::env::var("ALLOW_DEVELOPMENT_ATTESTATION") + .map(|v| v == "true" || v == "1") + .unwrap_or(false), }; config.validate()?; @@ -150,6 +163,32 @@ impl Config { tracing::debug!("Minimum App Version: {}", self.minimum_app_version); tracing::debug!("Redis URL: {}", self.redis_url); tracing::debug!("Ntfy Auth Token: [REDACTED]"); + tracing::debug!( + "Apple Team Identifier: {}", + self.apple_team_identifier.as_deref().unwrap_or("[NOT SET]") + ); + tracing::debug!( + "Apple Bundle Identifier: {}", + self.apple_bundle_identifier + .as_deref() + .unwrap_or("[NOT SET]") + ); + tracing::debug!( + "Android Package Name: {}", + self.android_package_name.as_deref().unwrap_or("[NOT SET]") + ); + tracing::debug!( + "Google Service Account: {}", + if self.google_service_account_json.is_some() { + "[SET]" + } else { + "[NOT SET]" + } + ); + tracing::debug!( + "Allow Development Attestation: {}", + self.allow_development_attestation + ); tracing::debug!("============================"); } } diff --git a/server/src/db/attestation_repo.rs b/server/src/db/attestation_repo.rs new file mode 100644 index 0000000..9827ce6 --- /dev/null +++ b/server/src/db/attestation_repo.rs @@ -0,0 +1,109 @@ +use anyhow::Result; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use sqlx::{PgPool, Postgres, Transaction}; + +#[derive(Debug, sqlx::FromRow)] +pub struct DeviceAttestation { + pub id: i64, + pub pubkey: String, + pub platform: String, + pub key_id: String, + pub public_key: Option, + pub receipt: Option>, + pub environment: String, + pub attestation_passed: bool, + pub failure_reason: Option, +} + +pub struct UpsertAttestationData<'a> { + pub pubkey: &'a str, + pub platform: &'a str, + pub key_id: &'a str, + pub public_key_bytes: Option<&'a [u8]>, + pub receipt: Option<&'a [u8]>, + pub environment: &'a str, + pub attestation_passed: bool, + pub failure_reason: Option<&'a str>, +} + +pub struct AttestationRepository<'a> { + pool: &'a PgPool, +} + +impl<'a> AttestationRepository<'a> { + pub fn new(pool: &'a PgPool) -> Self { + Self { pool } + } + + pub async fn find_by_pubkey_and_platform( + &self, + pubkey: &str, + platform: &str, + ) -> Result> { + let attestation = sqlx::query_as::<_, DeviceAttestation>( + r#" + SELECT id, pubkey, platform, key_id, public_key, receipt, + environment, attestation_passed, failure_reason + FROM device_attestations + WHERE pubkey = $1 AND platform = $2 + "#, + ) + .bind(pubkey) + .bind(platform) + .fetch_optional(self.pool) + .await?; + + Ok(attestation) + } + + pub async fn upsert( + tx: &mut Transaction<'_, Postgres>, + data: &UpsertAttestationData<'_>, + ) -> Result<()> { + let public_key_b64 = data.public_key_bytes.map(|bytes| BASE64.encode(bytes)); + + sqlx::query( + r#" + INSERT INTO device_attestations + (pubkey, platform, key_id, public_key, receipt, environment, attestation_passed, failure_reason) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (pubkey, platform) DO UPDATE SET + key_id = EXCLUDED.key_id, + public_key = EXCLUDED.public_key, + receipt = EXCLUDED.receipt, + environment = EXCLUDED.environment, + attestation_passed = EXCLUDED.attestation_passed, + failure_reason = EXCLUDED.failure_reason, + updated_at = now() + "#, + ) + .bind(data.pubkey) + .bind(data.platform) + .bind(data.key_id) + .bind(public_key_b64) + .bind(data.receipt) + .bind(data.environment) + .bind(data.attestation_passed) + .bind(data.failure_reason) + .execute(&mut **tx) + .await?; + + Ok(()) + } + + pub async fn has_valid_attestation(&self, pubkey: &str) -> Result { + let exists = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 FROM device_attestations + WHERE pubkey = $1 AND attestation_passed = true + ) + "#, + ) + .bind(pubkey) + .fetch_one(self.pool) + .await?; + + Ok(exists) + } +} diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs index 5693ba7..6c65674 100644 --- a/server/src/db/mod.rs +++ b/server/src/db/mod.rs @@ -1,3 +1,4 @@ +pub mod attestation_repo; pub mod backup_repo; pub mod device_repo; pub mod heartbeat_repo; diff --git a/server/src/lib.rs b/server/src/lib.rs index ced138e..6c71d4c 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,2 +1,3 @@ +pub mod attestation; pub mod config; mod constants; diff --git a/server/src/main.rs b/server/src/main.rs index 0efdffa..f0cb459 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -35,6 +35,7 @@ use crate::{ }; mod ark_client; +mod attestation; mod cron; pub mod db; mod errors; diff --git a/server/src/routes/public_api_v0.rs b/server/src/routes/public_api_v0.rs index 4c85e6e..e5fe74a 100644 --- a/server/src/routes/public_api_v0.rs +++ b/server/src/routes/public_api_v0.rs @@ -5,6 +5,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, }; +use bitcoin::Network; use expo_push_notification_client::Priority; use rand::Rng; use uuid::Uuid; @@ -15,7 +16,15 @@ use validator::{Validate, ValidateEmail}; use crate::{ AppState, - db::{device_repo::DeviceRepository, user_repo::UserRepository}, + attestation::{ + AndroidAttestationParams, IosAttestationParams, verify_android_integrity, + verify_ios_attestation, + }, + db::{ + attestation_repo::{AttestationRepository, UpsertAttestationData}, + device_repo::DeviceRepository, + user_repo::UserRepository, + }, errors::ApiError, push::{PushNotificationData, send_push_notification}, types::{ @@ -256,6 +265,72 @@ pub async fn lnurlp_request( )) } +type IosAttestationResultTuple = Option<( + bool, + Option, + Option, +)>; +type AndroidAttestationResultTuple = Option<( + bool, + Option, + Option, +)>; + +async fn store_attestation_results( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + pubkey: &str, + payload: &RegisterPayload, + ios_attestation_result: &IosAttestationResultTuple, + android_attestation_result: &AndroidAttestationResultTuple, +) -> Result<(), ApiError> { + if let Some((passed, failure_reason, result)) = ios_attestation_result { + let key_id = payload + .ios_attestation + .as_ref() + .map(|a| a.key_id.as_str()) + .unwrap_or(""); + AttestationRepository::upsert( + tx, + &UpsertAttestationData { + pubkey, + platform: "ios", + key_id, + public_key_bytes: result.as_ref().map(|r| r.public_key_bytes.as_slice()), + receipt: result.as_ref().map(|r| r.receipt.as_slice()), + environment: result + .as_ref() + .map(|r| r.environment.as_str()) + .unwrap_or("unknown"), + attestation_passed: *passed, + failure_reason: failure_reason.as_deref(), + }, + ) + .await?; + } + + if let Some((passed, failure_reason, result)) = android_attestation_result { + AttestationRepository::upsert( + tx, + &UpsertAttestationData { + pubkey, + platform: "android", + key_id: "", + public_key_bytes: None, + receipt: None, + environment: result + .as_ref() + .map(|r| r.environment.as_str()) + .unwrap_or("unknown"), + attestation_passed: *passed, + failure_reason: failure_reason.as_deref(), + }, + ) + .await?; + } + + Ok(()) +} + /// Handles user registration via LNURL-auth. /// /// This endpoint receives a user's public key, a signature, and a `k1` value. @@ -281,6 +356,123 @@ pub async fn register( auth_payload.k1, ); + // Check if we should verify attestation (skip on regtest) + let network = state.config.network().unwrap_or(Network::Regtest); + let should_verify_attestation = network != Network::Regtest; + + // Process iOS attestation if provided + let ios_attestation_result = if let Some(ref ios_attestation) = payload.ios_attestation { + if should_verify_attestation { + let team_id = state.config.apple_team_identifier.as_deref().unwrap_or(""); + let bundle_id = state + .config + .apple_bundle_identifier + .as_deref() + .unwrap_or(""); + + if team_id.is_empty() || bundle_id.is_empty() { + tracing::warn!( + "iOS attestation provided but APPLE_TEAM_IDENTIFIER or APPLE_BUNDLE_IDENTIFIER not configured" + ); + Some(( + false, + Some("Server not configured for iOS attestation".to_string()), + None, + )) + } else { + let params = IosAttestationParams { + attestation_base64: &ios_attestation.attestation, + challenge: &ios_attestation.challenge, + key_id: &ios_attestation.key_id, + bundle_identifier: bundle_id, + team_identifier: team_id, + allow_development: state.config.allow_development_attestation, + }; + + match verify_ios_attestation(params) { + Ok(result) => { + tracing::info!( + "iOS attestation verified for pubkey: {}, environment: {}", + auth_payload.key, + result.environment + ); + Some((true, None, Some(result))) + } + Err(e) => { + tracing::warn!( + "iOS attestation verification failed for pubkey: {}: {}", + auth_payload.key, + e + ); + Some((false, Some(e.to_string()), None)) + } + } + } + } else { + tracing::debug!("Skipping iOS attestation verification on regtest"); + None + } + } else { + None + }; + + // Process Android attestation if provided + let android_attestation_result = if let Some(ref android_attestation) = + payload.android_attestation + { + if should_verify_attestation { + let package_name = state.config.android_package_name.as_deref().unwrap_or(""); + let service_account_json = state + .config + .google_service_account_json + .as_deref() + .unwrap_or(""); + + if package_name.is_empty() || service_account_json.is_empty() { + tracing::warn!( + "Android attestation provided but ANDROID_PACKAGE_NAME or GOOGLE_SERVICE_ACCOUNT_JSON not configured" + ); + Some(( + false, + Some("Server not configured for Android attestation".to_string()), + None, + )) + } else { + let params = AndroidAttestationParams { + integrity_token: &android_attestation.integrity_token, + package_name, + expected_nonce: Some(&android_attestation.challenge), + service_account_json, + }; + + match verify_android_integrity(params).await { + Ok(result) => { + tracing::info!( + "Android attestation verified for pubkey: {}, environment: {}, device_verdict: {:?}", + auth_payload.key, + result.environment, + result.device_recognition_verdict + ); + Some((true, None, Some(result))) + } + Err(e) => { + tracing::warn!( + "Android attestation verification failed for pubkey: {}: {}", + auth_payload.key, + e + ); + Some((false, Some(e.to_string()), None)) + } + } + } + } else { + tracing::debug!("Skipping Android attestation verification on regtest"); + None + } + } else { + None + }; + if let Some(user) = user_repo.find_by_pubkey(&auth_payload.key).await? { tracing::debug!("User with pubkey: {} already registered", auth_payload.key); @@ -299,10 +491,20 @@ pub async fn register( return Err(e.into()); } - if let Some(device_info) = payload.device_info { + if let Some(ref device_info) = payload.device_info { // For existing users, we'll just register the device in its own transaction let mut tx = state.db_pool.begin().await?; - DeviceRepository::upsert(&mut tx, &auth_payload.key, &device_info).await?; + DeviceRepository::upsert(&mut tx, &auth_payload.key, device_info).await?; + + store_attestation_results( + &mut tx, + &auth_payload.key, + &payload, + &ios_attestation_result, + &android_attestation_result, + ) + .await?; + tx.commit().await?; } @@ -314,7 +516,7 @@ pub async fn register( })); } - let ln_address = payload.ln_address.unwrap_or_else(|| { + let ln_address = payload.ln_address.clone().unwrap_or_else(|| { let number = rand::rng().random_range(0..100); let random_word = random_word::get(random_word::Lang::En); format!("{}{}@{}", random_word, number, state.lnurl_domain) @@ -350,10 +552,19 @@ pub async fn register( return Err(e.into()); } - if let Some(device_info) = payload.device_info { - DeviceRepository::upsert(&mut tx, &auth_payload.key, &device_info).await?; + if let Some(ref device_info) = payload.device_info { + DeviceRepository::upsert(&mut tx, &auth_payload.key, device_info).await?; } + store_attestation_results( + &mut tx, + &auth_payload.key, + &payload, + &ios_attestation_result, + &android_attestation_result, + ) + .await?; + tx.commit().await?; Ok(Json(RegisterResponse { diff --git a/server/src/tests/common.rs b/server/src/tests/common.rs index 4ec339e..0351fa0 100644 --- a/server/src/tests/common.rs +++ b/server/src/tests/common.rs @@ -81,6 +81,11 @@ impl TestUser { minimum_app_version: "0.0.1".to_string(), redis_url: std::env::var("TEST_REDIS_URL") .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()), + apple_team_identifier: None, + apple_bundle_identifier: None, + android_package_name: None, + google_service_account_json: None, + allow_development_attestation: false, } } diff --git a/server/src/types.rs b/server/src/types.rs index c0db45d..d12f222 100644 --- a/server/src/types.rs +++ b/server/src/types.rs @@ -2,6 +2,28 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; use validator::Validate; +/// iOS App Attestation payload sent during registration +#[derive(Debug, Serialize, Deserialize, TS, Clone)] +#[ts(export, export_to = "../../client/src/types/serverTypes.ts")] +pub struct IosAttestationPayload { + /// Base64-encoded attestation object from attestKeyIos() + pub attestation: String, + /// The challenge/nonce used for attestation + pub challenge: String, + /// Key identifier from generateKeyIos() + pub key_id: String, +} + +/// Android Play Integrity payload sent during registration +#[derive(Debug, Serialize, Deserialize, TS, Clone)] +#[ts(export, export_to = "../../client/src/types/serverTypes.ts")] +pub struct AndroidAttestationPayload { + /// The integrity token from requestIntegrityTokenAndroid() + pub integrity_token: String, + /// The challenge/nonce used for attestation (hashed request data) + pub challenge: String, +} + #[derive(Deserialize, Debug, Clone, TS)] #[ts(export, export_to = "../../client/src/types/serverTypes.ts")] pub struct AuthPayload { @@ -55,6 +77,10 @@ pub struct RegisterPayload { pub device_info: Option, /// Optional Ark address pub ark_address: Option, + /// Optional iOS attestation data + pub ios_attestation: Option, + /// Optional Android attestation data + pub android_attestation: Option, } /// Defines the payload for registering a push notification token.