fix(pay): checksum Monero addresses (base58 + keccak) #7

Merged
sirius merged 2 commits from wt/t_45d30b55 into main 2026-09-11 00:03:11 +00:00
49 changed files with 10111 additions and 50 deletions
Showing only changes of commit 2041302da7 - Show all commits

14
.gitignore vendored
View file

@ -1,3 +1,17 @@
/target /target
/dist /dist
/.worktrees/ /.worktrees/
# Machine-local Android build config. sdk.dir/ndk.dir are absolute paths to
# whatever the developer has installed — never commit them.
/.android-env.sh
android/local.properties
# Gradle / Android build output
android/.gradle/
android/build/
android/app/build/
android/sdk/build/
# Rust: the SDK is its own Cargo workspace
crates/onionwire-sdk/target/

49
Cargo.lock generated
View file

@ -1557,6 +1557,17 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "futures-rustls"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb"
dependencies = [
"futures-io",
"rustls",
"rustls-pki-types",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.34" version = "0.3.34"
@ -3426,6 +3437,40 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
dependencies = [
"log",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@ -5339,11 +5384,15 @@ dependencies = [
"dyn-clone", "dyn-clone",
"educe", "educe",
"futures", "futures",
"futures-rustls",
"hex", "hex",
"libc", "libc",
"native-tls", "native-tls",
"paste", "paste",
"pin-project", "pin-project",
"rustls",
"rustls-pki-types",
"rustls-webpki",
"socket2", "socket2",
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",

View file

@ -8,8 +8,26 @@ license = "MIT"
publish = false publish = false
repository = "https://github.com/sirius0xdev/onionwire" repository = "https://github.com/sirius0xdev/onionwire"
# The Linux TUI build is unchanged: `default` keeps Arti's native-tls
# (OpenSSL) backend exactly as before, and ratatui remains a normal
# dependency so `src/tui.rs` is untouched.
#
# Android has no OpenSSL in the NDK, so the SDK crate depends on this crate
# with `default-features = false, features = ["rustls"]`. Arti marks these two
# backends non-additive, so exactly one may be selected per build graph — hence
# exposing them as crate features.
[features]
default = ["native-tls"]
native-tls = ["arti-client/native-tls"]
rustls = ["arti-client/rustls"]
[dependencies] [dependencies]
arti-client = { version = "0.46", features = ["tokio", "onion-service-client", "onion-service-service"] } arti-client = { version = "0.46", default-features = false, features = [
"tokio",
"onion-service-client",
"onion-service-service",
"compression",
] }
futures = "0.3" futures = "0.3"
safelog = "0.9" safelog = "0.9"
ed25519-dalek = { version = "2", features = ["rand_core"] } ed25519-dalek = { version = "2", features = ["rand_core"] }

View file

@ -208,7 +208,7 @@ If you set `ONIONWIRE_HOME`, delete that directory instead.
## Seized laptop ## Seized laptop
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in `messages.plaintext`), wrapped by a passphrase-derived Argon2id key. A disk grep of `onionwire.db` must not yield the message log. Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in `messages.plaintext`) with AAD bound to `friend_id`, `dir`, and row id, wrapped by a passphrase-derived Argon2id key. A disk grep of `onionwire.db` must not yield the message log. Empty-AAD v0.2 blobs are rewrapped once on unlock.
Still plaintext on disk (unless you add OS/FDE): Still plaintext on disk (unless you add OS/FDE):
@ -219,6 +219,33 @@ The message key is **not** wrapped with `identity_sk` (that key is in the same f
Threat model: [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md). Threat model: [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md).
## Android (SDK + APK)
Same repository, same protocol, separate product from the Linux TUI. An Android
peer and a Linux peer that exchange invites interoperate — one protocol, not two.
- **SDK**`onionwire-sdk-<version>.aar`. UniFFI Kotlin bindings over the same
Rust crate the TUI runs on, plus `libonionwire_sdk.so` for `arm64-v8a`.
Another Android app depends on it directly. See
[`crates/onionwire-sdk/README.md`](crates/onionwire-sdk/README.md).
- **APK**`onionwire-<version>-android-arm64-v8a.apk`. Kotlin + Jetpack
Compose + Material 3 messenger that depends on the SDK and nothing else.
It is not a WebView, and `src/tui.rs` is untouched. Both are built on a host with
the Android SDK/NDK — the Pi runner is aarch64 Linux and cannot produce an APK:
```bash
# one-time: sdkmanager platform 36 / build-tools 36.0.0 / ndk 28.2.13676358,
# rustup target add aarch64-linux-android, cargo install cargo-ndk
scripts/build-android-local.sh # -> dist/, with .sha256 files
PUBLISH_TAG=v0.3.0 scripts/build-android-local.sh # + upload to that release
```
Every asset has a matching `.sha256` so `sha256sum -c` works. The APK is
**debug-signed** and is for sideloading, not for Play. Full build notes, the
`minSdk 26` rationale, the 16 KB page-size check and the signing story are in
[`android/README.md`](android/README.md).
## Releases (maintainers) ## Releases (maintainers)
Tags trigger Forgejo Actions on the self-hosted Pi runner. The x86_64 asset has Tags trigger Forgejo Actions on the self-hosted Pi runner. The x86_64 asset has
@ -249,6 +276,7 @@ Scripts:
- [`scripts/publish-release.sh`](scripts/publish-release.sh) — create-or-update a release and (re)upload assets via the Forgejo API. Needs `FORGEJO_TOKEN` (repo secret in CI, env locally). - [`scripts/publish-release.sh`](scripts/publish-release.sh) — create-or-update a release and (re)upload assets via the Forgejo API. Needs `FORGEJO_TOKEN` (repo secret in CI, env locally).
- [`scripts/build-release-local.sh`](scripts/build-release-local.sh) — x86_64 build + strip + checksum + publish. - [`scripts/build-release-local.sh`](scripts/build-release-local.sh) — x86_64 build + strip + checksum + publish.
- [`scripts/build-android-local.sh`](scripts/build-android-local.sh) — Android AAR + APK build, checksums, optional publish (see the Android section above).
- [`scripts/release-body.md`](scripts/release-body.md) — release notes template (`@TAG@` is substituted). - [`scripts/release-body.md`](scripts/release-body.md) — release notes template (`@TAG@` is substituted).
Do not run `cargo publish`; `publish = false`. CI needs the repo secret Do not run `cargo publish`; `publish = false`. CI needs the repo secret

138
android/README.md Normal file
View file

@ -0,0 +1,138 @@
# OnionWire for Android
Two artifacts out of one Gradle build:
| Module | Output | What it is |
|---|---|---|
| `:sdk` | `sdk-release.aar` | The reusable library. UniFFI Kotlin bindings + `libonionwire_sdk.so`. Any Android app can depend on this. |
| `:app` | `app-release.apk` / `app-debug.apk` | A Jetpack Compose + Material 3 messenger that depends on `:sdk` and nothing else. |
The Linux TUI in the repository root is a **separate product** with a separate
Cargo workspace. Nothing here turns it into a WebView, and nothing here imports
its TUI code.
## Requirements
* JDK 17+ (built and tested on JDK 21)
* Android SDK with **platform 36** and **build-tools 36.0.0**
* NDK **28.2.13676358** (NDK r28c) — also what Rust's Android link step uses
* Rust with the `aarch64-linux-android` target and `cargo-ndk`
* `ANDROID_HOME` set, or `sdk.dir` in `android/local.properties` (gitignored)
One-time setup:
```bash
sdkmanager --install "platform-tools" "platforms;android-36" \
"build-tools;36.0.0" "ndk;28.2.13676358"
rustup target add aarch64-linux-android x86_64-linux-android
cargo install cargo-ndk
```
## Build
```bash
cd android
./gradlew :sdk:assembleRelease :app:assembleDebug # or :app:assembleRelease
```
The Rust cross-compile and the UniFFI Kotlin bindings are wired into the Gradle
tasks — there is no manual codegen step. `:sdk:preBuild` depends on them.
Emulator / x86_64 slice:
```bash
./gradlew :app:assembleDebug -Ponionwire.abis=arm64-v8a,x86_64
```
Or from the repo root, with checksums and an optional publish step:
```bash
scripts/build-android-local.sh # -> dist/onionwire-<v>-android-arm64-v8a.apk
PUBLISH_TAG=v0.3.0 scripts/build-android-local.sh
```
## Decisions you should know about
### `minSdk = 26` (Android 8.0)
The floor is not NDK-imposed — Rust's `aarch64-linux-android` std and NDK 28
both happily target API 21. We take 26 deliberately:
* it is the first API level where adaptive icons and notification channels are
unconditional, so there is one icon path instead of two;
* it keeps a single `arm64-v8a` APK inside Play's currently-supported range
without carrying legacy ART/JIT workarounds for pre-O devices;
* the crypto stack (`ring`, `rustls`, `argon2`, `chacha20poly1305`) is
exercised on 4.4+ kernels here, which is what we actually test.
**Devices cut:** Android 7.1 and older. If you need those, the change is
`-Ponionwire.api=24` **and** a real device test — do not bump it silently.
### 16 KB page size
The NDK r28 link step emits `max-page-size=16384` for 64-bit Android, which is
what Play requires for new apps. Verify with:
```bash
readelf -l android/sdk/build/rustJniLibs/arm64-v8a/libonionwire_sdk.so | grep LOAD
```
### Permissions
`INTERNET` only, declared at first launch. No camera (invites are pasted, not
scanned), no contacts, no storage permission — the data directory is
`context.filesDir`, which is already app-private. There is **no**
`FOREGROUND_SERVICE` and no keep-alive notification in v1: the node lives only
while the process does, and the UI says so. Adding one is a product decision
(battery, Play's FGS type declarations), not a silent fix.
### Data at rest
`context.filesDir/onionwire/` (mode 0700), holding `onionwire.db` and the Arti
state dir. `android:allowBackup="false"` plus explicit `data_extraction_rules`
exclusions, because an auto-backup of `onionwire.db` would hand the wrapped
message key *and* the plaintext identity keys to Google Drive. Chat bodies are
encrypted at rest; identity keys are not — that is the locked v1 posture.
### Signing
Both `debug` and `release` are signed with the **standard debug keystore**
(`~/.android/debug.keystore`, auto-created by AGP). This is what makes the
release APK installable for sideloading. It is **not** a Play upload key;
nothing here should be uploaded to Play. When a real upload key exists, put it
in `~/.gradle/gradle.properties` or CI secrets and reference it from
`signingConfigs` — never in this repository.
### Release builds are not minified
`isMinifyEnabled = false` for release. Keeps for the UniFFI/JNI surface already
exist in `sdk/consumer-rules.pro`, but enabling R8 without a mapping-file upload
path and an on-device test of the JNI surface is how you ship an
`UnsatisfiedLinkError` nobody can read. Turn it on together with crash
deobfuscation, not before.
## CI
`.forgejo/workflows/release.yml` runs on an aarch64 Linux container on the Pi.
It cannot build an APK. Android artifacts are built on a host with the SDK/NDK
via `scripts/build-android-local.sh`, exactly like the x86_64 Linux binary is
built via `scripts/build-release-local.sh`.
## Layout
```
android/
settings.gradle.kts
build.gradle.kts # plugin aliases only
gradle/libs.versions.toml # version catalog
sdk/ # -> AAR
build.gradle.kts # cargo-ndk + uniffi-bindgen wiring
consumer-rules.pro
src/main/AndroidManifest.xml
app/ # -> APK
build.gradle.kts
src/main/java/com/siriusdevops/onionwire/
MainActivity.kt # NavHost
WireViewModel.kt # owns the single Wire node
ui/ # Compose screens (M3)
```

View file

@ -0,0 +1,90 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.siriusdevops.onionwire"
compileSdk = 36
// Same ABI selection as :sdk. JNA ships libjnidispatch.so for several ABIs;
// without this filter the APK carries native code for ABIs we never built
// our Rust lib for.
val onionwireAbis: List<String> = (findProperty("onionwire.abis") as String?)
?.split(',')?.map(String::trim)?.filter(String::isNotEmpty)
?: listOf("arm64-v8a")
defaultConfig {
applicationId = "com.siriusdevops.onionwire"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
ndk {
abiFilters += onionwireAbis
}
}
buildTypes {
debug {
// Sideload / device-test builds. Signed with the standard debug key.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
// Deliberately NOT minified in this card: there is no on-device
// crash-deobfuscation story yet, and shipping R8 without testing
// the JNI/uniffi surface on a real device is how you get a
// mysterious UnsatisfiedLinkError in the field.
// Enable together with mapping-file upload; keeps already exist in
// :sdk/consumer-rules.pro.
isMinifyEnabled = false
isShrinkResources = false
// Signed with the debug keystore so the release APK installs.
// This is NOT a Play upload key — see android/README.md.
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(project(":sdk"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
debugImplementation(libs.compose.ui.tooling)
}

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
INTERNET is the only permission requested at first launch.
No camera (invites are typed/pasted, not scanned), no contacts, no
storage. Tor runs in-process; there is no foreground service and no
keep-alive notification in v1, so the node only lives while the app does.
-->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.OnionWire">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,120 @@
package com.siriusdevops.onionwire
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.siriusdevops.onionwire.ui.ChatScreen
import com.siriusdevops.onionwire.ui.PasteInviteScreen
import com.siriusdevops.onionwire.ui.RosterScreen
import com.siriusdevops.onionwire.ui.RotateScreen
import com.siriusdevops.onionwire.ui.ShareInviteScreen
import com.siriusdevops.onionwire.ui.UnlockScreen
import com.siriusdevops.onionwire.ui.OnionWireTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
OnionWireTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
AppRoot()
}
}
}
}
}
@Composable
private fun AppRoot(vm: WireViewModel = viewModel()) {
val phase by vm.phase.collectAsStateWithLifecycle()
when (val p = phase) {
is Phase.Ready -> ReadyGraph(vm, p)
else -> UnlockScreen(phase = p, onUnlock = vm::unlock)
}
}
@Composable
private fun ReadyGraph(vm: WireViewModel, ready: Phase.Ready) {
val nav = rememberNavController()
val friends by vm.friends.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
NavHost(navController = nav, startDestination = "roster") {
composable("roster") {
RosterScreen(
fingerprint = ready.fingerprint,
onion = ready.onion,
sdkVersion = ready.sdkVersion,
friends = friends,
onOpenChat = { nav.navigate("chat/${it.pubkeyHex}") },
onShare = { nav.navigate("share") },
onPaste = { nav.navigate("paste") },
onRotate = { nav.navigate("rotate") },
onWipe = { scope.launch { vm.wipeMessages() } },
onLock = vm::lock,
)
}
composable("share") {
ShareInviteScreen(ownerInvite = vm::invite, onBack = { nav.popBackStack() })
}
composable("paste") {
PasteInviteScreen(
onDecode = vm::decode,
onAdd = vm::addFriend,
onBack = { nav.popBackStack() },
)
}
composable("rotate") {
RotateScreen(
currentOnion = ready.onion,
onRotate = {
val out = vm.rotateOnion()
out.notified.toInt() to out.friends.toInt()
},
onBack = { nav.popBackStack() },
)
}
composable(
route = "chat/{pk}",
arguments = listOf(navArgument("pk") { type = NavType.StringType }),
) { entry ->
val pk = entry.arguments?.getString("pk").orEmpty()
val friend = friends.firstOrNull { it.pubkeyHex == pk }
ChatScreen(
title = friend?.petname?.takeIf { it.isNotBlank() }
?: friend?.fingerprint
?: pk.take(16),
fingerprint = friend?.fingerprint ?: pk.take(16),
onion = friend?.onion.orEmpty(),
loadMessages = { vm.messages(pk) },
onSend = { body -> vm.send(pk, body) },
onBack = { nav.popBackStack() },
)
}
}
}

View file

@ -0,0 +1,124 @@
package com.siriusdevops.onionwire
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import uniffi.onionwire_sdk.ChatMessage
import uniffi.onionwire_sdk.FriendInfo
import uniffi.onionwire_sdk.InviteInfo
import uniffi.onionwire_sdk.RotateOutcome
import uniffi.onionwire_sdk.Wire
import uniffi.onionwire_sdk.decodeInvite
import uniffi.onionwire_sdk.openWire
import java.io.File
sealed interface Phase {
/** No identity unlocked. Nothing Tor-related is running. */
data object Locked : Phase
/** Bootstrap + onion publish. Cold start is minutes, not seconds. */
data object Opening : Phase
data class Ready(
val fingerprint: String,
val onion: String,
val sdkVersion: String,
) : Phase
data class Failed(val message: String) : Phase
}
/**
* Owns the single Wire node for the process.
*
* The node lives in the ViewModel because a configuration change must not
* drop a published onion service and re-bootstrap Tor.
*/
class WireViewModel(app: Application) : AndroidViewModel(app) {
/** `context.filesDir` — app-private, never external storage. */
private val home: File = File(app.filesDir, "onionwire")
private val _phase = MutableStateFlow<Phase>(Phase.Locked)
val phase: StateFlow<Phase> = _phase.asStateFlow()
private val _friends = MutableStateFlow<List<FriendInfo>>(emptyList())
val friends: StateFlow<List<FriendInfo>> = _friends.asStateFlow()
private var wire: Wire? = null
/**
* Opens (or on first run, creates) the identity and starts hosting.
*
* An empty passphrase fails closed the SDK maps it straight to an error,
* it is not a "skip" path.
*/
fun unlock(passphrase: String) {
if (_phase.value is Phase.Opening) return
_phase.value = Phase.Opening
viewModelScope.launch {
try {
val w = openWire(home.absolutePath, passphrase)
wire = w
_phase.value = Phase.Ready(
fingerprint = w.fingerprint(),
onion = w.onion(),
sdkVersion = w.version(),
)
refreshFriends()
} catch (t: Throwable) {
_phase.value = Phase.Failed(t.message ?: t.toString())
}
}
}
/** Drops our handle. The onion service stops; nothing is sent after this. */
fun lock() {
wire = null
_friends.value = emptyList()
_phase.value = Phase.Locked
}
private fun requireWire(): Wire = wire ?: error("locked")
suspend fun refreshFriends() {
_friends.value = requireWire().friends()
}
suspend fun invite(): String = requireWire().invite()
suspend fun addFriend(invite: String): FriendInfo {
val f = requireWire().addFriend(invite)
refreshFriends()
return f
}
suspend fun setPetname(pubkeyHex: String, petname: String?) {
requireWire().setPetname(pubkeyHex, petname?.takeIf { it.isNotBlank() })
refreshFriends()
}
suspend fun messages(pubkeyHex: String): List<ChatMessage> =
requireWire().messages(pubkeyHex)
suspend fun send(pubkeyHex: String, body: String) =
requireWire().send(pubkeyHex, body)
suspend fun wipeMessages() {
requireWire().wipeMessages()
}
suspend fun rotateOnion(): RotateOutcome {
val out = requireWire().rotateOnion()
_phase.value = (_phase.value as? Phase.Ready)?.copy(onion = requireWire().onion())
?: _phase.value
refreshFriends()
return out
}
suspend fun decode(invite: String): InviteInfo = decodeInvite(invite)
}

View file

@ -0,0 +1,206 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import uniffi.onionwire_sdk.ChatMessage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
title: String,
fingerprint: String,
onion: String,
loadMessages: suspend () -> List<ChatMessage>,
onSend: suspend (String) -> Unit,
onBack: () -> Unit,
) {
var messages by remember { mutableStateOf<List<ChatMessage>?>(null) }
var draft by remember { mutableStateOf("") }
var pending by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
// Poll: the node appends inbound messages in the background; there is no
// push channel into the UI in v1.
LaunchedEffect(Unit) {
while (true) {
runCatching { loadMessages() }
.onSuccess { messages = it }
.onFailure { error = it.message ?: it.toString() }
delay(2_000)
}
}
// Fail-closed send. `send` retries the peer's onion for up to 3 minutes and
// then errors — nothing is spooled, so a failure is a real failure.
LaunchedEffect(pending) {
val body = pending ?: return@LaunchedEffect
error = null
runCatching { onSend(body) }
.onFailure { error = it.message ?: it.toString() }
pending = null
}
LaunchedEffect(messages?.size) {
val n = messages?.size ?: 0
if (n > 0) listState.animateScrollToItem(n - 1)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(
fingerprint,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
)
}
},
navigationIcon = {
IconButton(onClick = onBack) { Text("<") }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(Modifier.padding(pad).fillMaxSize()) {
Text(
"onion: $onion",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp),
)
val list = messages
when {
list == null -> {
Spacer(Modifier.height(16.dp))
CircularProgressIndicator(Modifier.padding(16.dp))
}
list.isEmpty() -> {
Column(
Modifier.fillMaxWidth().padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("No messages", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(6.dp))
Text(
"Sends fail closed: if their onion is down the message is " +
"not spooled anywhere. There is no outbox in v1.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else -> {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(list) { m ->
val mine = m.direction == "out"
Row(
Modifier.fillMaxWidth(),
horizontalArrangement =
if (mine) Arrangement.End else Arrangement.Start,
) {
Text(
m.body,
style = MaterialTheme.typography.bodyMedium,
color = if (mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.background(
if (mine) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(10.dp),
)
.padding(horizontal = 10.dp, vertical = 8.dp),
)
}
}
}
}
}
error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
Row(
Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.weight(1f),
maxLines = 4,
label = { Text("message") },
)
Spacer(Modifier.width(8.dp))
if (pending != null) {
CircularProgressIndicator()
} else {
Button(
enabled = draft.isNotBlank(),
onClick = {
pending = draft
draft = ""
},
) { Text("Send") }
}
}
}
}
}

View file

@ -0,0 +1,259 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import uniffi.onionwire_sdk.FriendInfo
import uniffi.onionwire_sdk.InviteInfo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareInviteScreen(ownerInvite: suspend () -> String, onBack: () -> Unit) {
var invite by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var copied by remember { mutableStateOf(false) }
val clip = LocalClipboardManager.current
LaunchedEffect(Unit) {
runCatching { ownerInvite() }
.onSuccess { invite = it }
.onFailure { error = it.message ?: it.toString() }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Your invite") },
navigationIcon = { IconButton(onClick = onBack) { Text("<") } },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(
Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
) {
Text(
"Hand this to one person, over a channel you already trust. " +
"It is not published anywhere and cannot be looked up.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
when {
error != null -> Text(
error!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
invite == null -> CircularProgressIndicator()
else -> {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Text(
invite!!,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
clip.setText(AnnotatedString(invite!!))
copied = true
}) { Text(if (copied) "Copied" else "Copy") }
}
Spacer(Modifier.height(20.dp))
Text("What is in it", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(6.dp))
Mono("k ed25519 identity pubkey — who you are")
Mono("o current onion locator — where you are now")
Mono("spk x25519 signed prekey — session start material")
Mono("sig signature over k‖o‖spk — reject if it fails")
Spacer(Modifier.height(12.dp))
Text(
"The onion in here goes stale when you rotate. The key does not. " +
"If you re-share later, the same k updates their entry — it never " +
"creates a second friend.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PasteInviteScreen(
onDecode: suspend (String) -> InviteInfo,
onAdd: suspend (String) -> FriendInfo,
onBack: () -> Unit,
) {
var raw by remember { mutableStateOf("") }
var preview by remember { mutableStateOf<InviteInfo?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var added by remember { mutableStateOf<FriendInfo?>(null) }
var busy by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add a friend") },
navigationIcon = { IconButton(onClick = onBack) { Text("<") } },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(
Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
) {
OutlinedTextField(
value = raw,
onValueChange = {
raw = it
preview = null
error = null
added = null
},
label = { Text("onionwire:v1:…") },
minLines = 4,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
enabled = raw.isNotBlank() && !busy,
onClick = {
busy = true
error = null
},
) { Text("Verify") }
if (added != null) {
TextButton(onClick = onBack) { Text("Done") }
}
}
LaunchedEffect(busy) {
if (!busy) return@LaunchedEffect
runCatching { onDecode(raw.trim()) }
.onSuccess { preview = it }
.onFailure { error = it.message ?: it.toString() }
busy = false
}
Spacer(Modifier.height(16.dp))
error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Text(
"Unsigned or tampered invites are rejected here, before anything is stored.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
preview?.let { p ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(12.dp)) {
Label("THEY ARE")
Mono(p.pubkeyHex)
Spacer(Modifier.height(8.dp))
Text(
"fingerprint ${p.pubkeyHex.take(16)}… — compare this with them " +
"over a second channel before you trust it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Label("REACHABLE AT")
Mono(p.onion)
}
}
Spacer(Modifier.height(12.dp))
Button(
enabled = !busy && added == null,
onClick = {
busy = true
error = null
},
) { Text("Approve & add") }
LaunchedEffect(busy, preview) {
if (!busy || preview == null || added != null) return@LaunchedEffect
runCatching { onAdd(raw.trim()) }
.onSuccess { added = it }
.onFailure { error = it.message ?: it.toString() }
busy = false
}
}
added?.let { f ->
Spacer(Modifier.height(16.dp))
Text(
"Added ${f.petname ?: f.fingerprint}",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(4.dp))
Text(
"If that key was already in your roster, only the locator was updated. " +
"One key is always one friend.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View file

@ -0,0 +1,229 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import uniffi.onionwire_sdk.FriendInfo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RosterScreen(
fingerprint: String,
onion: String,
sdkVersion: String,
friends: List<FriendInfo>,
onOpenChat: (FriendInfo) -> Unit,
onShare: () -> Unit,
onPaste: () -> Unit,
onRotate: () -> Unit,
onWipe: () -> Unit,
onLock: () -> Unit,
) {
var confirmWipe by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("OnionWire") },
actions = {
TextButton(onClick = onLock) { Text("Lock") }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(Modifier.padding(pad).fillMaxSize()) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(Modifier.padding(12.dp)) {
Label("YOU (stable forever)")
Mono(fingerprint)
Spacer(Modifier.height(10.dp))
Label("ONION (locator — rotates)")
Mono(onion)
Spacer(Modifier.height(10.dp))
Text(
"sdk $sdkVersion · identity = ed25519 pubkey",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(8.dp))
Row(
Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
TextButton(onClick = onShare) { Text("Invite") }
TextButton(onClick = onPaste) { Text("Add") }
TextButton(onClick = onRotate) { Text("Rotate") }
TextButton(onClick = { confirmWipe = true }) { Text("Wipe chat") }
}
if (confirmWipe) {
ConfirmStrip(
text = "Delete every message body? Identity and friends stay.",
confirm = "WIPE",
onConfirm = { confirmWipe = false; onWipe() },
onCancel = { confirmWipe = false },
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
if (friends.isEmpty()) {
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("No friends yet", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Share your invite over a channel you already trust, or paste " +
"someone else's. Invites are not discoverable — nothing is published.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(Modifier.fillMaxSize()) {
items(friends, key = { it.pubkeyHex }) { f ->
FriendRow(f) { onOpenChat(f) }
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun FriendRow(f: FriendInfo, onClick: () -> Unit) {
Column(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
f.petname?.takeIf { it.isNotBlank() } ?: f.fingerprint,
style = MaterialTheme.typography.titleSmall,
)
if (!f.lastConnectOk) {
Spacer(Modifier.height(0.dp))
Text(
" locator stale",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
Spacer(Modifier.height(2.dp))
Text(
f.fingerprint,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
)
Text(
f.onion,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
fun Label(text: String) {
Text(
text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
fun Mono(text: String) {
Text(
text,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
)
}
@Composable
fun ConfirmStrip(
text: String,
confirm: String,
onConfirm: () -> Unit,
onCancel: () -> Unit,
) {
var typed by remember { mutableStateOf("") }
Card(
modifier = Modifier.fillMaxWidth().padding(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(Modifier.padding(12.dp)) {
Text(text, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(8.dp))
androidx.compose.material3.OutlinedTextField(
value = typed,
onValueChange = { typed = it },
singleLine = true,
label = { Text("Type $confirm") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = onCancel) { Text("Cancel") }
TextButton(
onClick = onConfirm,
enabled = typed.trim().equals(confirm, ignoreCase = true),
) { Text("Confirm") }
}
}
}
}

View file

@ -0,0 +1,139 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Rotating publishes a brand new onion service and hard-cuts the old one.
*
* It is a typed confirmation on purpose: it is not a single tap, and it is not
* hidden. Friends who are online get a signed `loc` update; friends who are
* offline cannot find you again until they re-scan your invite. Your identity
* key and therefore your fingerprint does not change.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RotateScreen(
currentOnion: String,
onRotate: suspend () -> Pair<Int, Int>, // notified to friends
onBack: () -> Unit,
) {
var typed by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Rotate onion") },
navigationIcon = { IconButton(onClick = onBack) { Text("<") } },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(
Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
) {
Text("Rotate onion address?", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(12.dp))
Text("Your identity key stays the same.", style = MaterialTheme.typography.bodyMedium)
Text(
"Online friends get a signed location update.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
"Offline friends CANNOT find you until they re-scan your invite.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(16.dp))
Label("CURRENT")
Mono(currentOnion)
Spacer(Modifier.height(20.dp))
if (result == null) {
OutlinedTextField(
value = typed,
onValueChange = { typed = it },
singleLine = true,
label = { Text("Type ROTATE to confirm") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
enabled = typed.trim().equals("ROTATE", ignoreCase = true) &&
!busy,
onClick = { busy = true; error = null },
) { Text("Rotate") }
if (busy) CircularProgressIndicator()
}
}
LaunchedEffect(busy) {
if (!busy) return@LaunchedEffect
runCatching { onRotate() }
.onSuccess { result = it }
.onFailure { error = it.message ?: it.toString() }
busy = false
}
error?.let {
Spacer(Modifier.height(16.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
result?.let { (notified, friends) ->
Spacer(Modifier.height(20.dp))
Text(
"Rotated · notified $notified/$friends friends",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
Text(
"The old onion service is already gone. Anyone still holding the old " +
"invite will fail to connect — that is the documented v1 behaviour, " +
"not a bug.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Button(onClick = onBack) { Text("Back — share the new invite") }
}
}
}
}

View file

@ -0,0 +1,34 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val OnionViolet = Color(0xFF7C4DFF)
private val WireCyan = Color(0xFF38F2C7)
private val DeepVoid = Color(0xFF0B0B10)
private val PanelVoid = Color(0xFF15151C)
private val OnionWireColors = darkColorScheme(
primary = WireCyan,
onPrimary = DeepVoid,
primaryContainer = OnionViolet,
onPrimaryContainer = Color.White,
secondary = OnionViolet,
onSecondary = Color.White,
background = DeepVoid,
onBackground = Color(0xFFE6E6F0),
surface = DeepVoid,
onSurface = Color(0xFFE6E6F0),
surfaceVariant = PanelVoid,
onSurfaceVariant = Color(0xFFB9B9CC),
outline = Color(0xFF3A3A4A),
error = Color(0xFFFF6B6B),
onError = DeepVoid,
)
@Composable
fun OnionWireTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = OnionWireColors, content = content)
}

View file

@ -0,0 +1,130 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.siriusdevops.onionwire.Phase
@Composable
fun UnlockScreen(phase: Phase, onUnlock: (String) -> Unit) {
var pass by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("ONIONWIRE", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(4.dp))
Text(
"Tor messenger. No server, no account. Your identity is a key.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(32.dp))
when (phase) {
is Phase.Opening -> {
Text("Publishing onion service…", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
Text(
"First launch bootstraps Arti and uploads a v3 onion descriptor. " +
"On a cold network this takes minutes, not seconds. Keep the screen on.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
CircularProgressIndicator()
}
else -> {
Text(
"Passphrase",
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = pass,
onValueChange = { pass = it },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
label = { Text("wraps the local message key") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Text(
"Empty is not a bypass — it fails closed. There is no recovery: " +
"lose this and the stored chat bodies stay unreadable.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(20.dp))
Button(
onClick = { onUnlock(pass) },
enabled = pass.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text("Open") }
if (phase is Phase.Failed) {
Spacer(Modifier.height(20.dp))
Text(
"Failed to start",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(4.dp))
Text(
(phase as Phase.Failed).message,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Text(
"OnionWire fails closed: if the onion service cannot publish, " +
"it does not fall back to a clearnet or C-tor path.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.height(24.dp))
Text(
"INTERNET is the only permission this app requests.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0B0B10"
android:pathData="M0,0h108v108h-108z" />
</vector>

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Onion bulb. Adaptive-icon foreground: keep art inside the 66dp safe zone. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- bulb -->
<path
android:fillColor="#7C4DFF"
android:pathData="M54,28 C72,28 82,46 82,62 C82,78 69,88 54,88 C39,88 26,78 26,62 C26,46 36,28 54,28 Z" />
<!-- sprout -->
<path
android:strokeColor="#38F2C7"
android:strokeWidth="3"
android:strokeLineCap="round"
android:pathData="M54,28 L54,18" />
<!-- layers -->
<path
android:strokeColor="#38F2C7"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:fillColor="#00000000"
android:pathData="M38,40 C33,54 35,74 48,84" />
<path
android:strokeColor="#38F2C7"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:fillColor="#00000000"
android:pathData="M70,40 C75,54 73,74 60,84" />
<path
android:strokeColor="#0B0B10"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:fillColor="#00000000"
android:pathData="M54,32 L54,86" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">OnionWire</string>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Compose paints the real UI; this only sets the window before the
first frame so there is no white flash on a dark app. -->
<style name="Theme.OnionWire" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">#FF0B0B10</item>
<item name="android:statusBarColor">#FF0B0B10</item>
<item name="android:navigationBarColor">#FF0B0B10</item>
</style>
</resources>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Chat bodies are encrypted at rest, but nothing is cloud-synced or
device-transferred: an Android auto-backup of onionwire.db would hand the
wrapped message key and the plaintext identity keys to Google Drive.
Identity is local by design — back it up with the SDK's owbak1 export instead.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
</cloud-backup>
<device-transfer>
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
</device-transfer>
</data-extraction-rules>

6
android/build.gradle.kts Normal file
View file

@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

12
android/gradle.properties Normal file
View file

@ -0,0 +1,12 @@
# Gradle
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=false
# Android
android.useAndroidX=true
android.nonTransitiveRClass=true
# Kotlin
kotlin.code.style=official

View file

@ -0,0 +1,31 @@
[versions]
agp = "8.13.2"
kotlin = "2.2.21"
coreKtx = "1.17.0"
activityCompose = "1.12.4"
lifecycle = "2.9.4"
navigationCompose = "2.9.8"
composeBom = "2025.12.01"
jna = "5.19.1"
coroutines = "1.11.0"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored Executable file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,163 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
// ---------------------------------------------------------------------------
// Rust: build crates/onionwire-sdk for the requested Android ABIs and generate
// the UniFFI Kotlin bindings from the host cdylib.
//
// Nothing here touches the Linux TUI crate's feature graph: the SDK crate is
// its own Cargo workspace that selects Arti's rustls backend.
// ---------------------------------------------------------------------------
val repoRoot: File = rootProject.projectDir.parentFile
val sdkCrateDir: File = repoRoot.resolve("crates/onionwire-sdk")
/** NDK version used for the Rust link step. Must match an installed NDK. */
val onionwireNdkVersion: String = (findProperty("onionwire.ndk") as String?)
?: "28.2.13676358"
/** Compile against this API level. Keep in sync with `minSdk`. */
val onionwireApiLevel: Int = ((findProperty("onionwire.api") as String?) ?: "26").toInt()
val androidSdkDir: File = sequenceOf(
System.getenv("ANDROID_HOME"),
System.getenv("ANDROID_SDK_ROOT"),
).filterNotNull().map(::File).firstOrNull()
?: rootProject.file("local.properties")
.takeIf { it.exists() }
?.readLines()
?.firstOrNull { it.startsWith("sdk.dir=") }
?.substringAfter('=')
?.trim()
?.let(::File)
?: error("ANDROID_HOME / ANDROID_SDK_ROOT unset and no sdk.dir in android/local.properties")
val ndkDir: File = androidSdkDir.resolve("ndk/$onionwireNdkVersion")
require(ndkDir.isDirectory) {
"NDK $onionwireNdkVersion not found at $ndkDir — run: sdkmanager --install \"ndk;$onionwireNdkVersion\""
}
val cargoExe: String = (findProperty("onionwire.cargo") as String?)
?: System.getenv("CARGO")
?: File(System.getProperty("user.home"), ".cargo/bin/cargo").takeIf { it.canExecute() }?.absolutePath
?: "cargo"
/** `-Ponionwire.abis=arm64-v8a,x86_64` to build more than one ABI. */
val onionwireAbis: List<String> = (findProperty("onionwire.abis") as String?)
?.split(',')?.map(String::trim)?.filter(String::isNotEmpty)
?: listOf("arm64-v8a")
val jniLibsDir = layout.buildDirectory.dir("rustJniLibs")
val uniffiKotlinDir = layout.buildDirectory.dir("generated/uniffi")
/** Host cdylib. UniFFI's `--library` mode dlopens it, so it must be host-arch. */
val cargoHostLib by tasks.registering(Exec::class) {
group = "onionwire"
description = "Builds libonionwire_sdk.so for the host (used only to emit bindings)."
workingDir = sdkCrateDir
environment("ANDROID_HOME", androidSdkDir.absolutePath)
commandLine(cargoExe, "build", "--release", "--lib")
}
val uniffiBindgen by tasks.registering(Exec::class) {
group = "onionwire"
description = "Generates the Kotlin UniFFI bindings into build/generated/uniffi."
dependsOn(cargoHostLib)
workingDir = sdkCrateDir
inputs.file(sdkCrateDir.resolve("src/lib.rs"))
outputs.dir(uniffiKotlinDir)
environment("ANDROID_HOME", androidSdkDir.absolutePath)
doFirst { uniffiKotlinDir.get().asFile.mkdirs() }
commandLine(
cargoExe, "run", "--release", "--bin", "uniffi-bindgen", "--",
"generate",
"--library", "target/release/libonionwire_sdk.so",
"--language", "kotlin",
"--no-format",
"--config", "uniffi.toml",
"--out-dir", uniffiKotlinDir.get().asFile.absolutePath,
)
}
val cargoBuildAndroid by tasks.registering(Exec::class) {
group = "onionwire"
description = "Cross-compiles crates/onionwire-sdk for ${onionwireAbis.joinToString()}."
workingDir = sdkCrateDir
inputs.dir(sdkCrateDir.resolve("src"))
inputs.file(sdkCrateDir.resolve("Cargo.toml"))
inputs.file(sdkCrateDir.resolve("Cargo.lock"))
inputs.dir(repoRoot.resolve("src"))
inputs.file(repoRoot.resolve("Cargo.toml"))
outputs.dir(jniLibsDir)
environment("ANDROID_HOME", androidSdkDir.absolutePath)
environment("ANDROID_NDK_HOME", ndkDir.absolutePath)
environment("NDK_HOME", ndkDir.absolutePath)
commandLine(
buildList {
add(cargoExe); add("ndk")
onionwireAbis.forEach { add("-t"); add(it) }
add("--platform"); add(onionwireApiLevel.toString())
add("-o"); add(jniLibsDir.get().asFile.absolutePath)
add("build"); add("--release"); add("--lib")
}
)
}
// ---------------------------------------------------------------------------
android {
namespace = "com.siriusdevops.onionwire.sdk"
compileSdk = 36
ndkVersion = onionwireNdkVersion
defaultConfig {
minSdk = onionwireApiLevel
consumerProguardFiles("consumer-rules.pro")
ndk {
abiFilters += onionwireAbis
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
sourceSets["main"].jniLibs.srcDir(jniLibsDir)
sourceSets["main"].kotlin.srcDir(uniffiKotlinDir)
buildTypes {
release {
isMinifyEnabled = false
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
// UniFFI's Kotlin backend talks to the Rust cdylib through JNA direct
// mapping, and the async bindings need coroutines. Both are part of the
// public API surface, so they are `api` — a consumer of the AAR needs them
// on its own compile classpath. JNA's `aar` variant is what carries
// libjnidispatch.so for each ABI.
api(variantOf(libs.jna) { artifactType("aar") })
api(libs.kotlinx.coroutines.core)
}
tasks.withType<KotlinCompile>().configureEach {
dependsOn(uniffiBindgen)
}
tasks.matching { it.name == "preBuild" }.configureEach {
dependsOn(cargoBuildAndroid, uniffiBindgen)
}

View file

@ -0,0 +1,9 @@
# Applied to any app that depends on :sdk (AAR consumer rules).
#
# UniFFI calls back into Kotlin from the Rust side over JNI, so the generated
# classes are reachable only via reflection/native lookup. Without these keeps
# an R8-enabled consumer app strips them and fails at load time.
-keep class uniffi.onionwire_sdk.** { *; }
-keepclasseswithmembernames class uniffi.onionwire_sdk.** {
native <methods>;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The only permission the SDK itself needs: Tor runs over INTERNET. -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View file

@ -0,0 +1,28 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "onionwire-android"
// The Android library that ships as the AAR (Kotlin bindings + native .so).
include(":sdk")
// The installable Compose messenger. Depends on :sdk only — never on the TUI.
include(":app")

6567
crates/onionwire-sdk/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
[package]
name = "onionwire-sdk"
version = "0.1.0"
edition = "2024"
rust-version = "1.91"
description = "OnionWire Android/Kotlin SDK: in-process Arti onion transport, Noise IK, identity = ed25519 pubkey."
license = "MIT"
publish = false
# Standalone workspace on purpose.
#
# Arti's TLS backends (`native-tls` vs `rustls`) are non-additive: exactly one
# may be enabled per feature resolution. The Linux TUI crate keeps native-tls,
# Android has no OpenSSL in the NDK and needs rustls. Two separate workspaces
# keep the two resolutions from colliding — this crate path-depends on the
# root package without joining its feature graph.
[workspace]
[lib]
name = "onionwire_sdk"
crate-type = ["cdylib", "lib"]
[dependencies]
onionwire = { path = "../..", default-features = false, features = ["rustls"] }
# `static-sqlite`: Android has no system libsqlite3 to link against.
arti-client = { version = "0.46", default-features = false, features = [
"tokio",
"onion-service-client",
"onion-service-service",
"compression",
"rustls",
"static-sqlite",
] }
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
uniffi = { version = "0.32", features = ["cli", "tokio"] }
thiserror = "2"
[[bin]]
name = "uniffi-bindgen"
path = "src/bin/uniffi-bindgen.rs"

View file

@ -0,0 +1,122 @@
# onionwire-sdk (Android)
Kotlin-facing UniFFI bindings over the same Rust crate the OnionWire Linux TUI
runs on. An Android peer and a Linux peer that exchange invites interoperate —
there is one protocol, not two.
This is **not** a WebView wrapper and **not** a client for a hosted service.
Each install runs Arti in-process (`arti-client` 0.46, `onion-service-client` +
`onion-service-service`), hosts its own v3 onion service, and dials friends'
onions directly. There is no `tor` binary, no torrc, no Orbot as the pipe.
## Locked protocol facts
| Piece | Rule |
|---|---|
| Identity | ed25519 keypair. The pubkey **is** who you are. |
| Roster key | `friends.pubkey`, `UNIQUE`. Petnames are local only. |
| Onion | A **locator**, not an identity. It rotates. |
| Invite | `onionwire:v1:k=…:o=…:spk=…:sig=…` |
| Crypto | Noise IK (`Noise_IK_25519_ChaChaPoly_BLAKE2s`). `spk` is x25519. |
| Fail closed | Peer onion down → the send fails. No outbox, no spool in v1. |
| At rest | Chat bodies encrypted (Argon2id wrap + ChaCha20-Poly1305). Identity keys in sqlite are plaintext — that is honest, not an oversight. |
### Invite string
```
onionwire:v1:k=<ed25519 pubkey hex64>:o=<v3 onion address>:spk=<x25519 prekey hex64>:sig=<ed25519 sig hex128>
```
* `sig` is a signature over the concatenation `k ‖ o ‖ spk` as raw ASCII bytes.
* A malformed, unsigned, or tampered invite is rejected before anything is stored.
* **Same `k` never creates a second friend.** It updates the stored locator on
the existing row. That is the whole point of keying the roster on the pubkey.
* The invite carries no display name and no Monero address — those arrive later
over the wire as signed `prf` frames, not through the invite.
## Adding the AAR to another Android app
The SDK ships as `onionwire-sdk-<version>.aar` on the Forgejo release, with a
matching `.sha256`.
```kotlin
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
flatDir { dirs("libs") } // or however you vendor the AAR
}
}
// app/build.gradle.kts
dependencies {
implementation(files("libs/onionwire-sdk-0.1.0.aar"))
}
```
Requirements:
* `minSdk >= 26`. The AAR is built with the NDK against `android-26`.
* `abiFilters` must include an ABI the AAR ships. The release AAR includes
`arm64-v8a`. Add `x86_64` at build time with `-Ponionwire.abis=arm64-v8a,x86_64`
if you need an emulator.
* Keep `uniffi.onionwire_sdk.**` (see the AAR's `consumer-rules.pro`, applied
automatically by AGP) if you enable R8.
## Kotlin surface
Everything is a `suspend fun` — Arti bootstrap and onion publish take minutes,
and a send retries the peer for up to 3 minutes before failing.
```kotlin
import uniffi.onionwire_sdk.*
// 1. Open (or create) the identity inside app-private storage.
// `filesDir`, never external storage. Empty passphrase fails closed.
val wire: Wire = openWire(File(context.filesDir, "onionwire").absolutePath, passphrase)
// 2. Who you are, and where you are right now.
val fingerprint: String = wire.fingerprint() // stable forever
val onion: String = wire.onion() // changes on rotate
// 3. Invite out / invite in.
val mine: String = wire.invite()
val preview: InviteInfo = decodeInvite(pasted) // verifies sig, stores nothing
val friend: FriendInfo = wire.addFriend(pasted)
// 4. Roster and chat.
val roster: List<FriendInfo> = wire.friends()
wire.setPetname(friend.pubkeyHex, "ada")
wire.send(friend.pubkeyHex, "hello wire")
val msgs: List<ChatMessage> = wire.messages(friend.pubkeyHex)
// 5. Locator rotation. Do NOT wire this to a single tap — the app that embeds
// the SDK owns the typed confirmation.
val out: RotateOutcome = wire.rotateOnion() // out.notified / out.friends
```
All peer references are lowercase hex strings so nothing Rust-shaped leaks into
the AAR.
## Building the bindings yourself
The Gradle task `:sdk:uniffiBindgen` runs, inside `crates/onionwire-sdk`:
```
cargo build --release --lib # host cdylib
cargo run --release --bin uniffi-bindgen -- generate \
--library target/release/libonionwire_sdk.so \
--language kotlin --out-dir <generated dir>
```
Do not hand-edit anything under `android/sdk/build/generated/`; it is a build
output.
## Why this crate has its own Cargo workspace
Arti's TLS backends are **non-additive**: exactly one of `native-tls` and
`rustls` may be enabled in a given feature resolution. The Linux TUI keeps
`native-tls` (OpenSSL, as it always has). Android has no OpenSSL in the NDK, so
this crate selects `rustls` plus `static-sqlite` (no system `libsqlite3` on
Android). Two separate workspaces keep the two resolutions from colliding.

View file

@ -0,0 +1,8 @@
//! UniFFI binding generator entry point.
//!
//! `cargo run -p onionwire-sdk --bin uniffi-bindgen -- generate --library \
//! target/release/libonionwire_sdk.so --language kotlin --out-dir <dir>`
fn main() {
uniffi::uniffi_bindgen_main()
}

View file

@ -0,0 +1,229 @@
//! OnionWire Android/Kotlin SDK.
//!
//! UniFFI façade over the existing `onionwire` library — the same crate the
//! Linux TUI runs on. Nothing about the wire protocol is reimplemented here:
//! an Android peer and a Linux peer that exchange invites interoperate.
//!
//! Locked facts this surface assumes:
//! * Identity is an ed25519 pubkey. Fingerprint is a display of that key.
//! * `onionwire:v1:k=…:o=…:spk=…:sig=…` — the onion is a *locator* and can
//! change; re-scanning the same `k` updates the locator, never duplicates
//! the friend.
//! * Transport is in-process Arti. There is no `tor` binary, no torrc, no
//! Orbot pipe. If the onion service cannot publish, this fails closed.
//! * Chat bodies are encrypted at rest. Identity keys in sqlite are not.
//!
//! The façade deliberately exposes only plain data (records + hex strings) so
//! no TUI/ratatui type leaks into the AAR.
use std::path::PathBuf;
use std::sync::Arc;
use onionwire::node::Node;
use onionwire::qr;
uniffi::setup_scaffolding!();
#[derive(Debug, thiserror::Error, uniffi::Error)]
#[uniffi(flat_error)]
pub enum WireError {
#[error("{message}")]
Failed { message: String },
}
impl WireError {
fn new(e: impl std::fmt::Display) -> Self {
Self::Failed {
message: e.to_string(),
}
}
}
type WResult<T> = std::result::Result<T, WireError>;
#[derive(Debug, Clone, uniffi::Record)]
pub struct FriendInfo {
/// ed25519 identity pubkey, lowercase hex. This is the roster key.
pub pubkey_hex: String,
/// Display form of the same key.
pub fingerprint: String,
/// Local label. Never sent on the wire.
pub petname: Option<String>,
/// Current locator. May be stale if the peer rotated while you were down.
pub onion: String,
pub last_connect_ok: bool,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ChatMessage {
/// "in" or "out".
pub direction: String,
pub body: String,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct RotateOutcome {
pub notified: u32,
pub friends: u32,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct InviteInfo {
pub pubkey_hex: String,
pub onion: String,
}
/// A running OnionWire node: identity + Arti client + hosted v3 onion service.
#[derive(uniffi::Object)]
pub struct Wire {
inner: Arc<Node>,
}
/// Open (or create) the identity in `home` and start hosting an onion service.
///
/// `home` must be app-private storage (`context.filesDir`), not external.
/// `passphrase` wraps the local message key; an empty passphrase fails closed.
///
/// This resolves only once the onion service is published — on a cold network
/// that is minutes, not seconds. Call it off the main thread.
#[uniffi::export(async_runtime = "tokio")]
pub async fn open_wire(home: String, passphrase: String) -> WResult<Arc<Wire>> {
let node = Node::start_with_passphrase(PathBuf::from(home), &passphrase)
.await
.map_err(WireError::new)?;
Ok(Arc::new(Wire { inner: node }))
}
/// Decode an invite without adding the friend. Verifies the signature.
#[uniffi::export]
pub fn decode_invite(invite: String) -> WResult<InviteInfo> {
let p = qr::decode(&invite).map_err(WireError::new)?;
Ok(InviteInfo {
pubkey_hex: hex(&p.pubkey),
onion: p.onion,
})
}
#[uniffi::export(async_runtime = "tokio")]
impl Wire {
/// Fingerprint of our own identity key. Stable forever for this install.
pub async fn fingerprint(&self) -> WResult<String> {
self.inner.fingerprint().map_err(WireError::new)
}
/// Current onion locator. Changes on rotate; identity does not.
pub async fn onion(&self) -> String {
self.inner.onion()
}
/// `onionwire:v1:…` invite string to hand to the other person out of band.
pub async fn invite(&self) -> WResult<String> {
self.inner.qr_payload().map_err(WireError::new)
}
/// Roster, sorted by petname then fingerprint.
pub async fn friends(&self) -> WResult<Vec<FriendInfo>> {
let list = self.inner.list_friends().map_err(WireError::new)?;
Ok(list
.into_iter()
.map(|f| FriendInfo {
pubkey_hex: hex(&f.pubkey),
fingerprint: f.fingerprint,
petname: f.petname,
onion: f.onion,
last_connect_ok: f.last_connect_ok,
})
.collect())
}
/// Add a friend from an invite, or update the locator if the key is known.
/// Same `k` never creates a second row.
pub async fn add_friend(&self, invite: String) -> WResult<FriendInfo> {
self.inner.add_friend_from_qr(&invite).map_err(WireError::new)?;
let p = qr::decode(&invite).map_err(WireError::new)?;
let f = self.inner.friend(&p.pubkey).map_err(WireError::new)?;
Ok(FriendInfo {
pubkey_hex: hex(&f.pubkey),
fingerprint: f.fingerprint,
petname: f.petname,
onion: f.onion,
last_connect_ok: f.last_connect_ok,
})
}
/// Local label. Pass `None` to clear. Never goes on the wire.
pub async fn set_petname(&self, pubkey_hex: String, petname: Option<String>) -> WResult<()> {
let pk = unhex(&pubkey_hex)?;
self.inner
.set_petname(&pk, petname.as_deref())
.map_err(WireError::new)
}
/// Send one message. Fail closed: if the peer's onion is down, this errors
/// after retrying. There is no outbox in v1 — no silent spooling.
pub async fn send(&self, pubkey_hex: String, body: String) -> WResult<()> {
let pk = unhex(&pubkey_hex)?;
self.inner
.send(&pk, body.as_bytes())
.await
.map_err(WireError::new)
}
/// Chat history for a friend, oldest first. Bodies are decrypted here;
/// they are stored encrypted at rest.
pub async fn messages(&self, pubkey_hex: String) -> WResult<Vec<ChatMessage>> {
let pk = unhex(&pubkey_hex)?;
let rows = self.inner.list_messages(&pk).map_err(WireError::new)?;
Ok(rows
.into_iter()
.map(|m| ChatMessage {
direction: m.dir,
body: String::from_utf8_lossy(&m.plaintext).into_owned(),
})
.collect())
}
/// Delete every message body. Identity and friends survive.
pub async fn wipe_messages(&self) -> WResult<()> {
self.inner.wipe_messages().map_err(WireError::new)
}
/// Publish a NEW onion service and hard-cut the old one, then push a signed
/// location update to every reachable friend.
///
/// The identity key does not change. Friends who are offline cannot find
/// you until they rescan your invite — that is the documented v1 behaviour,
/// not a bug. Confirm with a typed phrase in the UI; never one tap.
pub async fn rotate_onion(&self) -> WResult<RotateOutcome> {
let r = self.inner.rotate().await.map_err(WireError::new)?;
Ok(RotateOutcome {
notified: r.notified as u32,
friends: r.friends as u32,
})
}
/// SDK version, for diagnostics.
pub async fn version(&self) -> String {
env!("CARGO_PKG_VERSION").to_string()
}
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn unhex(s: &str) -> WResult<Vec<u8>> {
if s.is_empty() || !s.len().is_multiple_of(2) || !s.bytes().all(|c| c.is_ascii_hexdigit()) {
return Err(WireError::new("pubkey_hex must be even-length hex"));
}
(0..s.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| WireError::new(format!("hex: {e}")))
})
.collect()
}

View file

@ -0,0 +1,9 @@
# UniFFI Kotlin bindings configuration.
#
# `disable_java_cleaner`: the JVM cleaner path pulls in androidx.annotation and
# branches on API 34. JNA's own Cleaner works on every API level we support, so
# we take the single code path and drop the extra dependency.
[bindings.kotlin]
package_name = "uniffi.onionwire_sdk"
disable_java_cleaner = true
kotlin_target_version = "2.2.21"

194
docs/SECURITY_AUDIT.md Normal file
View file

@ -0,0 +1,194 @@
# OnionWire Security Audit
Commit: 2b42864ebaaef2aab64e66055ee482c525180f63 (`2b42864`)
Baseline: `cargo test --locked` **pass** (100 passed, 3 ignored), `cargo clippy --locked --all-targets -- -D warnings` **pass**
Auditor: rust-dev (no access to running hidden services / live Monero wallet)
Tree: worktree `wt/t_d85060fb` at `/home/lancelot/Projects/onionwire/.worktrees/t_d85060fb`
Remote: `origin/main` = same SHA (`https://forgejo.siriusdevops.com/sirius/onionwire.git`)
Ignored tests (`needs live Tor network`): `rotate_hs`, `tor_hs`, `two_node`. Not re-run.
## Severity key
Critical = remote key compromise or plaintext disclosure
High = local key/plaintext disclosure, authn bypass, or payment forgery
Medium = DoS, nonce/IV weakness, metadata leak
Low = hygiene, error-path leakage, docs mismatch
Info = observation
## Findings
### F1 — Incoming receipt `verified=1` on unrelated wallet history [High]
Location: `src/wallet.rs:142-148`, used at `src/node.rs:667-683`
Evidence: confirmation is not “this txid paid this amount to this address”.
```142:148:src/wallet.rs
pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address: &str) -> bool {
rows.iter().any(|r| {
r.txid == txid
|| (!address.is_empty()
&& r.address == address
&& (amount.is_empty() || r.amount == amount))
})
}
```
`ingest_receipt` verifies the **ed25519** on the `rcp` frame (so the *friend* signed it), inserts `verified: false`, then flips `verified` if `transfers_match` is true (`src/node.rs:671-682`). Replica of that predicate against two wallet rows `{txid:aaa111, amount:1000, addr:4AAA…}` and `{txid:bbb222, amount:5, addr:8BBB…}`:
```
txid-only match (wrong amount+addr): True
addr+amount match (wrong txid): True
honest miss: False
```
Impact: a friend who completed Noise IK can send a signed receipt for an arbitrary amount/address and get the TUI line `[receipt] N XMR` (verified) if *either* (a) `txid` appears anywhere in `get_transfers` `in`/`pending`, or (b) some inbound row already has that address and amount. That is payment forgery against the local “verified” bit. It is not a third-party wire injection: the frame still has to decrypt under the pinned session. `docs/THREAT_MODEL.md:33` says “Never trust a `rcp` frame without RPC confirmation (`verified` stays 0)” — the code *does* promote `verified`, and the RPC check does not bind amount+address+txid together. Tests encode the store default (`tests/pay.rs` `incoming_receipt_is_not_verified`) but never exercise `transfers_match` against mismatched amount.
Fix: require `txid == row.txid && amount == row.amount && address == row.address` (and reject empty fields). Do not OR. Keep `verified=0` if RPC is down.
### F2 — Chat AEAD has empty AAD; ciphertext rows are interchangeable [Medium]
Location: `src/backup.rs:98-108` (`aead_encrypt`), `src/store.rs:649` / `670`
Evidence: bodies are `nonce || ChaCha20-Poly1305(key, nonce, pt)` with no associated data. The same 32-byte `msg_key` wraps every row. A DB writer who cannot open the passphrase can still swap `messages.plaintext` blobs. Throwaway against this tree (`/tmp/ow-audit-repro`, `CARGO_TARGET_DIR` = this worktree `target`):
```
SWAP: alice sees "secret-for-bob"
SWAP: bob sees "secret-for-alice"
```
Both `list_messages` calls returned `Ok`; Poly1305 verified. `dir` / `friend_id` / `id` / `created_at` are plaintext columns and are not in the MAC.
Impact: anyone with write access to `onionwire.db` (same uid, stolen unlocked file, or a bug that writes sqlite) can reattribute ciphertext across friends and in/out without the passphrase. This is *not* remote plaintext disclosure. Identity secret keys are already plaintext in `self` (threat model says so); this is extra: the body encryption does not bind a row to its owner. Nonces are 96-bit random per `generate_nonce` — reuse across restarts/`wipe-all` (new key) / first-unlock rewrap is not the failure mode here.
Fix: encrypt as `Aead::encrypt` with AAD = `friend_id || dir || row_id` (or a committed header), or include those fields in the plaintext that is MACed. Reject decrypt if AAD does not match the row.
### F3 — Invite `sig` is over concatenated strings, not length-prefixed fields [Medium]
Location: `src/qr.rs:67-72` (`sign_msg`), `src/qr.rs:36-64` (`decode`)
Evidence: `sig` covers `k.as_bytes() || onion.as_bytes() || spk.as_bytes()` with no delimiters or lengths. `k` is 64 hex chars after the 32-byte check, so it cannot shift. `o` and `spk` can. Encode a real v3 onion + 32-byte `spk`, then move the first 8 hex chars of `spk` onto `o`, keep `k` and `sig`. `qr::decode` **accepts** the mutant:
```
CONCAT: mutated invite accepted
CONCAT: onion_changed=true
CONCAT: onion=abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz234567.onionabababab
CONCAT: spk_len=28 (want 32)
CONCAT: pubkey_unchanged=true
```
`decode` does not require `signed_prekey.len()==32` or a v3 onion. `Node::add_friend_payload` then `upsert_friend` (same `k` **replaces** locator) and `set_friend_prekey`. Duplicate/unknown fields are rejected (`src/qr.rs:85-106`); all four fields are required. Empty `sig` fails `from_hex`. This is not a classic steal-the-identity concat: `k` stays the signer.
Impact: a mutated invite still verifies under the real identity key. F3-paste (or a same-`k` rescan) can poison `friends.onion` / `prekey` for that pubkey. The shifted onion is not an arbitrary attacker HS (you can only append a hex prefix of the original `spk`), so this is roster integrity / availability, not a silent MITM. Handshake then fails (`prekey` length ≠ 32 at `node.rs:388`). No panic on this path. `from_hex` has **no size cap**: a 4,000,000-char hex string decoded to 2,000,000 bytes in 0.34s in CPython; `decode` allocates that before `pubkey.len()!=32` rejects.
Fix: sign a domain-separated encoding (`k` || `0x00` || `o` || `0x00` || `spk`, or length prefixes). Reject `spk` ≠ 32 bytes and onion ≠ v3. Cap invite length before `from_hex` (a few KiB).
### F4 — Monero address check is prefix+length, not checksum [Medium]
Location: `src/pay.rs:38-48`; tests *require* the junk form to pass (`tests/pay.rs:15-50`)
Evidence:
```38:48:src/pay.rs
pub fn check_address(addr: &str) -> Result<()> {
let ok = match addr.as_bytes().first() {
Some(b'4') if addr.len() == 95 || addr.len() == 106 => true,
Some(b'8') if addr.len() == 95 => true,
_ => false,
};
if ok && !addr.contains('\n') {
Ok(())
} else {
Err(Error("invalid Monero address".into()))
}
}
```
`4` + `'A' * 94` is accepted (len 95). No network byte, no Keccak checksum, no alphabet check. `profile::check_fields` (`src/profile.rs:118-128`) does not call `check_address` at all — `xmr_addr` may be `"4abc"` (`tests/profile.rs`). `/tip` does call `check_address` on the stored profile address (`src/node.rs:331`). `/pay` invoices are signed over that address (`src/node.rs:290`).
Impact: OnionWire will persist and sign invoices/receipts for strings no Monero wallet should pay. A live `monero-wallet-rpc` will usually reject checksum failures on `transfer`, so this is not by itself silent theft. It *is* a local fail-open: garbage becomes a signed `inv`/`rcp` payload and a payments row. Combined with F1, a verified receipt can cite such an address.
Fix: decode base58, check network prefix + checksum. Empty profile `xmr_addr` stays allowed; non-empty must pass the same check.
### F5 — Wallet RPC is unauthenticated HTTP and can mark receipts from its full transfer list [Medium]
Location: `src/wallet.rs:151-171`, `223-237`, `117-138`; README documents `ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083`
Evidence: URL parser requires `http://` (no TLS), host must be loopback or `.onion` (`allowed_host`, tested in `tests/wallet.rs` `refuse_non_loopback_non_onion_host`). There is no `user:pass` / Digest / header. `http_post` is raw `TcpStream` + `read_to_end` with a 5s timeout, no body size cap. `.onion` hosts are allowed but `TcpStream::connect((onion, port))` does **not** go through Arti — so an onion RPC URL fail-closes at connect (not examined live). `eprintln!("ONIONWIRE_WALLET_RPC: {e}")` prints the parse error, not the URL, on bad env.
Impact: the documented operator setup is “HTTP to loopback, no login”. Any local process that can reach that port can `transfer` (spend) and `get_transfers` (the same list F1 trusts). OnionWire never holds spend keys (threat model — true); it also never authenticates to the process that does. This is local, not remote, if the operator actually bound loopback. Code cannot express `--rpc-login`.
Fix: require digest (or a unix socket). Refuse URLs without credentials. Cap RPC read size. If onion RPC is a goal, dial it through the Arti client, not `TcpStream`.
### F6 — `/wipe` does not touch payments; sqlite is not `secure_delete` [Low]
Location: `src/store.rs:677-685`, `122-125`
Evidence: `wipe_messages` overwrites `messages.plaintext` with `zeroblob(length)`, `DELETE FROM messages`, `VACUUM`. No `PRAGMA secure_delete`. No `DELETE FROM payments`. WAL is required (`journal_mode = WAL` fail-closed). Threat model (`docs/THREAT_MODEL.md:21`) says “`/wipe` overwrites message bodies and vacuums” — that part matches. It does not say payments go away; they do not. Identity / friend pubkeys / onions stay plaintext (disclosed, not a finding). `onionwire.db` itself is never `chmod 0600`; the home and `arti/` dirs are `0700` after `create_dir_all` (`src/store.rs:753-758`, `tests/store.rs:61-62`). Backup files *are* `0o600` at create (`src/node.rs:145-150`).
Impact: `/wipe` is not a forensic erase (SSD wear-leveling, WAL snapshots, payments table, roster). A seized disk after `/wipe` still has who you pay and who you talk to. `/wipe-all` is `remove_dir_all` — same disk caveat.
Fix: if `/wipe` should mean “chat history gone”, also drop `payments` (and checkpoint WAL). Document that `/wipe` is not crypto-shred. Optional `secure_delete` is still not a guarantee on flash.
### F7 — HS publish logs the onion; `dangerously_trust_everyone` is Arti-only [Low]
Location: `src/hs.rs:31-36`, `src/hs.rs:95`, `src/node.rs:95`
Evidence: `wait_until_published(..., &onion, &onion)` uses the unredacted onion as `label`. `eprintln!("{label} hs status: {state:?}")` and probe lines go to stderr. `onion_string` uses `display_unredacted` (`src/hs.rs:70`) — required to persist the locator; the leak is the log. `client_config` calls `builder.storage().permissions().dangerously_trust_everyone()` after `create_dir_all` on the Arti state/cache paths. That API is fs-mistrust for **Artis** directories, not sqlite. `Store::open_at_with_passphrase` `mkdir_700`s `home` and `home/arti` first; `home/cache` is created by Artis `create_dir_all` without `0700`. `cbtmintimeout` / `cbtinitialtimeout` = 20s is a circuit-build floor (perf / publish reliability), not an auth bypass.
Impact: journald/script logs contain the current v3 locator. Arti state/cache may be created `0755` until something else tightens them; sqlite lives under the `0700` home. A world-readable Arti cache is descriptor/consensus metadata, not chat bodies.
Fix: log a redacted onion (safelog). `mkdir_700` the cache dir before `client_config`. Keep `dangerously_trust_everyone` scoped to Arti storage; do not reuse it for `onionwire.db`.
### F8 — Threat model overstates receipt verification and omits F1F3 [Low]
Location: `docs/THREAT_MODEL.md:17-21`, `:33`, `:39-41`
Evidence: TM correctly describes live-only send, loc rules, identity-vs-locator, passphrase-wrapped message key, plaintext identity/roster, experimental Arti, global 30/60s burst-10 token bucket, backup = identity. It claims RPC confirmation keeps `verified` at 0 unless the chain view agrees — F1 shows the matcher is not that. It does not mention empty AEAD AAD, invite concat, shape-only XMR addresses, or env passphrase (`ONIONWIRE_STORE_PASSPHRASE` in `src/store.rs:745-750`, visible in `/proc/<pid>/environ`).
Impact: an operator who treats TM as the capability list will believe “verified receipt ⇒ wallet saw that payment”.
Fix: either implement F1s conjunctive match or change the sentence to “incoming `rcp` is displayed; `verified` is best-effort and must not be trusted in v0.2”.
## Verified correct
- Noise pattern is actually `Noise_IK_25519_ChaChaPoly_BLAKE2s` with prologue `onionwire-v1` (`src/session.rs:8-9, 111-122`). Initiator sets `remote_public_key` to the QR/roster x25519 prekey (`src/session.rs:189`). Responder takes remote static from snow (`get_remote_static`) and looks up the friend (`src/session.rs:255-260`, `src/node.rs:550-555`).
- Mutual identity proofs are `ed25519_sign(handshake_hash)` with the 32-byte pubkey prefix; `verify_proof` calls `VerifyingKey::verify` (`src/session.rs:289-310`). Initiator compares proof prefix to the **pinned** identity (`src/session.rs:219-222`); responder to the roster id (`src/session.rs:273-276`). Mismatch is `Error::mismatch` → send hard-fails (`src/node.rs:490`).
- Transport nonces are snow `TransportState` counters (Noise spec: increment, reject reuse). Application code does not set ChaCha nonces on the wire. Session keys include ephemeral DH → compromise of long-term static does not decrypt **past** transport; it does allow impersonation **forward**. That is IK, not a bug.
- Loc frames: `apply_loc` verifies ed25519 against the **session** `peer_identity`, requires the pubkey already in `friends`, and requires `ts > onion_updated_at` (`src/store.rs:452-480`, `src/loc.rs:38-55`, `src/node.rs:561-568`). A peer cannot silently move you to an onion they control unless they hold that identity key (Noise proof + loc sig). An old loc with smaller `ts` cannot rewind after a later rotate. (F3 invite paste can still overwrite locator; that is out-of-band.)
- Unknown typed prefixes (`xyz `) are `Kind::Drop`, not fatal (`src/dispatch.rs:30-38`). Unparseable loc/inv/rcp are ignored (`src/node.rs:562`, `610-611`, `643-644`). Handshake/frame errors print `incoming: {e}` and the rend task ends; the accept loop continues (`src/node.rs:723-729`).
- Frames are length-prefixed, `MAX_FRAME = 65535`, checked **before** allocating the body (`src/frame.rs:5, 63-67`).
- Token bucket is **one global** `TokenBucket::default()` = 30 tokens / 60s, burst 10 (`src/ratelimit.rs:41-44`, `src/node.rs:86, 712-720`). Matches TM; one flood can starve every friend (availability, not auth).
- Backup: Argon2id v0x13, `m=19456` KiB, `t=2`, `p=1` (`src/backup.rs:52-53`) = OWASP 2023 minimum. Per-export 16-byte salt + 12-byte random nonce in the file. `open` AEAD-fails with a single error before `replace_identity_keys` (`src/backup.rs:91-95`, `src/node.rs:155-168`). Onion is not in the blob (`tests/backup.rs`). Wrong passphrase does not write keys.
- First-run message key: 32 random bytes, wrapped with the same KDF/AEAD, stored in `store_meta` (`src/store.rs:281-293`). Empty passphrase refused. Integrity check fail-closed (`src/store.rs:191-194`).
- Amounts on the pay path are decimal **integer piconero** (`src/pay.rs:51-59`); `xmr_to_atomic` pads a ≤12-digit fraction without float (`src/pay.rs:62-85`). `/tip` then `parse`s to `u64` for RPC (`src/node.rs:332-334`).
- Incoming `inv`/`rcp` are verified against **session peer identity**, not a field inside the frame (`src/node.rs:613`, `646`).
- Locked product decisions (no server/XMPP/MAM/DHT, live-only send, identity=pubkey, onion=locator, no dual-host grace) match the code. Not findings.
## Not examined / out of scope
- Live hidden-service reachability, IPT/HsDir, and two-node Tor tests (ignored; no HS from this auditor).
- Live `monero-wallet-rpc` (auth defaults, `get_transfers` JSON shape vs `json_amount`, unlock/spend confirm).
- snow 0.10 internals beyond the `Builder`/`TransportState` API used here (constant-time, rekey at 2^64).
- Arti keystore encryption at rest, fs-mistrust semantics of `dangerously_trust_everyone` beyond “it is called on Arti storage”.
- Timing of Argon2 / ed25519 verify (failed backup passphrase is one error string; KDF still runs).
- TUI rendering of hostile chat (ratatui text; no HTML).
- Traffic analysis / HS existence (TM already declines that).
- `cargo audit`: **not installed** (`which cargo-audit` empty). Lockfile inspected by hand; no RustSec lookup was executed against this `Cargo.lock`.
## Dependencies (Cargo.lock)
| Crate | Lock version | Cargo.toml |
|---|---|---|
| snow | 0.10.0 | `0.10` |
| chacha20poly1305 | 0.10.1 | `0.10` |
| argon2 | 0.5.3 | `0.5` |
| ed25519-dalek | 2.2.0 | `2` |
| x25519-dalek | 2.0.1 | `2` |
| rusqlite | 0.36.0 | `0.36` (bundled) |
| arti-client | 0.46.0 | `0.46` + `onion-service-client` + `onion-service-service` |
| tor-hsservice | 0.46.0 | `0.46` |
Caret reqs are not `=`; `cargo update` can move 0.10.x / 0.46.x without a Cargo.toml edit. `--locked` CI is the real pin. Arti onion services are still experimental upstream (TM + onionwire skill); this tree fail-closes, no C-tor fallback (`src/hs.rs:55`).
## Open questions for Lance
- Is a “verified” receipt allowed to mean anything in v0.2, or should the UI only ever show “unverified” until F1 is conjunctive and covered by a test?
- Invite encoding: length-prefix / `0x00` separators now, or wait for `onionwire:v2` (v1 strings stay in the wild)?
- Store passphrase: keep `ONIONWIRE_STORE_PASSPHRASE` (proc-visible) or prompt / kernel keyring?
- Wallet RPC: document “loopback + `--rpc-login` you type into a wrapper”, or teach OnionWire digest?
## Stop / go (auditor, not a ship decision)
Go for **friends-only chat** under the written seizure model (identity keys plaintext; bodies encrypted; Tor relays are not a server). **Do not** treat `verified` receipts as money moved. **Do not** treat F3-paste of a string you did not copy yourself as an integrity-checked locator. No Critical remote key/plaintext bug found in this revision.

View file

@ -16,7 +16,7 @@ A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friends locator **only
## Message bodies are encrypted at rest; keys are not ## Message bodies are encrypted at rest; keys are not
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in the `messages.plaintext` column). A random 32-byte data key is wrapped with Argon2id (same params as identity backup) from a non-empty passphrase. Salt + wrapped key live in `store_meta`. Unlock is fail-closed: wrong or empty passphrase does not open chat. Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in the `messages.plaintext` column) with AAD `owmsg1 || friend_id_le64 || dir || 0x00 || row_id_le64`. Swapping ciphertext between rows fails closed. A random 32-byte data key is wrapped with Argon2id (same params as identity backup) from a non-empty passphrase. Salt + wrapped key live in `store_meta`. Unlock is fail-closed: wrong or empty passphrase does not open chat. Empty-AAD v0.2 blobs are rewrapped once on unlock; `list_messages` never falls back to empty AAD.
Identity secret key, friend public keys, and locators remain plaintext in the same db. The message key is not wrapped with `identity_sk` (that key is already on disk). A seized laptop still yields who you talk to and your identity unless you add OS/FDE. sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. Identity secret key, friend public keys, and locators remain plaintext in the same db. The message key is not wrapped with `identity_sk` (that key is already on disk). A seized laptop still yields who you talk to and your identity unless you add OS/FDE. sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir.
@ -30,7 +30,7 @@ A signed `prf` frame is shown to people who already have a session with you. App
## Monero sidecar is not a wallet ## Monero sidecar is not a wallet
OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP to a user-hosted `monero-wallet-rpc`. Never trust a `rcp` frame without RPC confirmation (`verified` stays 0). Subaddress reuse is the users wallet policy. OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP to a user-hosted `monero-wallet-rpc`. A Noise friend can sign any `rcp`; the signature proves who sent the claim, not that a payment happened. `verified=1` only after a conjunctive RPC match: one `get_transfers` row with the same non-empty `txid`, `amount`, and `address`. Incoming `rcp` stays `verified=0` if RPC is down, errors, or no exact row. Subaddress reuse is the users wallet policy.
## Backup file is the identity ## Backup file is the identity

66
scripts/build-android-local.sh Executable file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Build the Android artifacts on this host and publish them to a Forgejo
# release. There is no Android toolchain in CI: .forgejo/workflows/release.yml
# runs on an aarch64 Linux container and cannot produce an APK. This is the
# Android path, exactly like scripts/build-release-local.sh is the x86_64 path
# for the Linux binary.
#
# Usage:
# scripts/build-android-local.sh [version] # build + checksums only
# PUBLISH_TAG=v0.3.0 scripts/build-android-local.sh # also upload
#
# Requires: ANDROID_HOME (or android/local.properties), an installed NDK, the
# Rust android targets, cargo-ndk, JDK 17+, and a working `cargo` on PATH.
# Does not create or push tags: creating the tag is a human decision.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
version="${1:-$(sed -n 's/.*versionName = "\(.*\)".*/\1/p' android/app/build.gradle.kts | head -1)}"
[ -n "$version" ] || { echo "could not determine version" >&2; exit 1; }
abi="${ONIONWIRE_ANDROID_ABI:-arm64-v8a}"
gradlew="$root/android/gradlew"
echo "==> android build: version $version, abi $abi"
( cd android && "./gradlew" --no-daemon \
"-Ponionwire.abis=$abi" \
:sdk:assembleRelease :app:assembleRelease )
mkdir -p dist
apk_src="android/app/build/outputs/apk/release/app-release.apk"
aar_src="android/sdk/build/outputs/aar/sdk-release.aar"
apk="dist/onionwire-$version-android-$abi.apk"
aar="dist/onionwire-sdk-$version.aar"
for f in "$apk_src" "$aar_src"; do
[ -s "$f" ] || { echo "missing or empty build output: $f" >&2; exit 1; }
done
cp "$apk_src" "$apk"
cp "$aar_src" "$aar"
# Refuse 0-byte release assets before they ever reach a release.
for f in "$apk" "$aar"; do
bytes=$(wc -c < "$f")
[ "$bytes" -gt 0 ] || { echo "refusing to ship empty $f" >&2; exit 1; }
( cd dist && sha256sum "$(basename "$f")" > "$(basename "$f").sha256" )
done
echo "==> verify"
( cd dist && sha256sum -c ./*.sha256 )
file "$apk" "$aar"
ls -l "$apk" "$aar" "$apk.sha256" "$aar.sha256"
if [ -n "${PUBLISH_TAG:-}" ]; then
: "${FORGEJO_TOKEN:?FORGEJO_TOKEN required to publish}"
echo "==> publishing to release $PUBLISH_TAG"
scripts/publish-release.sh "$PUBLISH_TAG" "OnionWire $PUBLISH_TAG" \
scripts/release-body.md \
"$apk" "$apk.sha256" "$aar" "$aar.sha256"
else
echo "==> not publishing (set PUBLISH_TAG=<tag> and FORGEJO_TOKEN to upload)"
fi

View file

@ -1,7 +1,7 @@
//! Encrypted identity backup. Onion (locator) is not included. //! Encrypted identity backup. Onion (locator) is not included.
use argon2::{Algorithm, Argon2, Params, Version}; use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng}; use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::RngCore; use rand::RngCore;
@ -95,11 +95,21 @@ pub fn open(passphrase: &str, blob: &[u8]) -> Result<BackupKeys, String> {
BackupKeys::from_bytes(&pt) BackupKeys::from_bytes(&pt)
} }
pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>, String> { pub(crate) fn aead_encrypt(
key: &[u8; 32],
plaintext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, String> {
let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let ct = cipher let ct = cipher
.encrypt(&nonce, plaintext) .encrypt(
&nonce,
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| "encrypt failed".to_string())?; .map_err(|_| "encrypt failed".to_string())?;
let mut out = Vec::with_capacity(NONCE_LEN + ct.len()); let mut out = Vec::with_capacity(NONCE_LEN + ct.len());
out.extend_from_slice(&nonce); out.extend_from_slice(&nonce);
@ -107,13 +117,19 @@ pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>,
Ok(out) Ok(out)
} }
pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8]) -> Result<Vec<u8>, String> { pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8], aad: &[u8]) -> Result<Vec<u8>, String> {
if blob.len() < NONCE_LEN + 16 { if blob.len() < NONCE_LEN + 16 {
return Err("ciphertext length".into()); return Err("ciphertext length".into());
} }
let nonce = Nonce::from_slice(&blob[..NONCE_LEN]); let nonce = Nonce::from_slice(&blob[..NONCE_LEN]);
let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
cipher cipher
.decrypt(nonce, &blob[NONCE_LEN..]) .decrypt(
nonce,
Payload {
msg: &blob[NONCE_LEN..],
aad,
},
)
.map_err(|_| "wrong passphrase or corrupt".to_string()) .map_err(|_| "wrong passphrase or corrupt".to_string())
} }

View file

@ -201,6 +201,24 @@ impl Node {
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// Fingerprint of our own identity key (identity = pubkey).
pub fn fingerprint(&self) -> Result<String, String> {
self.store
.lock()
.map_err(|e| e.to_string())?
.self_fingerprint()
.map_err(|e| e.to_string())
}
/// Local-only label. Does not go on the wire.
pub fn set_petname(&self, pubkey: &[u8], petname: Option<&str>) -> Result<(), String> {
self.store
.lock()
.map_err(|e| e.to_string())?
.set_petname(pubkey, petname)
.map_err(|e| e.to_string())
}
pub fn friend(&self, pubkey: &[u8]) -> Result<Friend, String> { pub fn friend(&self, pubkey: &[u8]) -> Result<Friend, String> {
self.store self.store
.lock() .lock()

View file

@ -20,30 +20,51 @@ pub struct QrPayload {
pub signed_prekey: Vec<u8>, pub signed_prekey: Vec<u8>,
} }
/// `onionwire:v1:k={pubkey_hex}:o={v3onion}:spk={signed_prekey}:sig={sign(k||o||spk)}` /// Cap invite text before hex decode. Honest v1 is a few hundred bytes.
const MAX_INVITE: usize = 4096;
const HEX_K: usize = 64;
const HEX_SPK: usize = 64;
const HEX_SIG: usize = 128;
/// `onionwire:v1:k={pubkey_hex}:o={v3onion}:spk={signed_prekey}:sig={sign(v2)}`
pub fn encode(identity_sk: &[u8], onion: &str, signed_prekey: &[u8]) -> Result<String> { pub fn encode(identity_sk: &[u8], onion: &str, signed_prekey: &[u8]) -> Result<String> {
if signed_prekey.len() != 32 {
return Err(Error("spk must be 32 bytes".into()));
}
if !is_v3_onion(onion) {
return Err(Error("o must be a v3 onion".into()));
}
let sk_bytes: [u8; 32] = identity_sk let sk_bytes: [u8; 32] = identity_sk
.try_into() .try_into()
.map_err(|_| Error("identity secret key must be 32 bytes".into()))?; .map_err(|_| Error("identity secret key must be 32 bytes".into()))?;
let sk = SigningKey::from_bytes(&sk_bytes); let sk = SigningKey::from_bytes(&sk_bytes);
let k = to_hex(&sk.verifying_key().to_bytes()); let k = to_hex(&sk.verifying_key().to_bytes());
let spk = to_hex(signed_prekey); let spk = to_hex(signed_prekey);
let msg = sign_msg(&k, onion, &spk); let msg = sign_msg_v2(&k, onion, &spk);
let sig = to_hex(&sk.sign(&msg).to_bytes()); let sig = to_hex(&sk.sign(&msg).to_bytes());
Ok(format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}")) Ok(format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}"))
} }
pub fn decode(raw: &str) -> Result<QrPayload> { pub fn decode(raw: &str) -> Result<QrPayload> {
if raw.len() > MAX_INVITE {
return Err(Error("invite too long".into()));
}
let (k, onion, spk, sig) = parse_fields(raw)?; let (k, onion, spk, sig) = parse_fields(raw)?;
if !is_hex_len(&k, HEX_K) {
return Err(Error("k must be 32 bytes".into()));
}
if !is_hex_len(&spk, HEX_SPK) {
return Err(Error("spk must be 32 bytes".into()));
}
if !is_hex_len(&sig, HEX_SIG) {
return Err(Error("sig must be 64 bytes".into()));
}
if !is_v3_onion(&onion) {
return Err(Error("o must be a v3 onion".into()));
}
let pubkey = from_hex(&k)?; let pubkey = from_hex(&k)?;
let signed_prekey = from_hex(&spk)?; let signed_prekey = from_hex(&spk)?;
let sig_bytes = from_hex(&sig)?; let sig_bytes = from_hex(&sig)?;
if pubkey.len() != 32 {
return Err(Error("k must be 32 bytes".into()));
}
if sig_bytes.len() != 64 {
return Err(Error("sig must be 64 bytes".into()));
}
let pk_arr: [u8; 32] = pubkey let pk_arr: [u8; 32] = pubkey
.as_slice() .as_slice()
.try_into() .try_into()
@ -54,9 +75,15 @@ pub fn decode(raw: &str) -> Result<QrPayload> {
.map_err(|_| Error("sig must be 64 bytes".into()))?; .map_err(|_| Error("sig must be 64 bytes".into()))?;
let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| Error(e.to_string()))?; let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| Error(e.to_string()))?;
let signature = Signature::from_bytes(&sig_arr); let signature = Signature::from_bytes(&sig_arr);
let msg = sign_msg(&k, &onion, &spk); let ok_v2 = vk
vk.verify(&msg, &signature) .verify(&sign_msg_v2(&k, &onion, &spk), &signature)
.map_err(|_| Error("bad signature".into()))?; .is_ok();
let ok_v1 = vk
.verify(&sign_msg_v1(&k, &onion, &spk), &signature)
.is_ok();
if !ok_v2 && !ok_v1 {
return Err(Error("bad signature".into()));
}
Ok(QrPayload { Ok(QrPayload {
pubkey, pubkey,
onion, onion,
@ -64,7 +91,30 @@ pub fn decode(raw: &str) -> Result<QrPayload> {
}) })
} }
fn sign_msg(k: &str, onion: &str, spk: &str) -> Vec<u8> { fn is_v3_onion(s: &str) -> bool {
let Some(addr) = s.strip_suffix(".onion") else {
return false;
};
addr.len() == 56 && addr.bytes().all(|b| matches!(b, b'a'..=b'z' | b'2'..=b'7'))
}
fn is_hex_len(s: &str, n: usize) -> bool {
s.len() == n && s.bytes().all(|c| c.is_ascii_hexdigit())
}
fn sign_msg_v2(k: &str, onion: &str, spk: &str) -> Vec<u8> {
let mut msg = Vec::with_capacity(19 + 3 + k.len() + onion.len() + spk.len());
msg.extend_from_slice(b"onionwire-invite-v1");
msg.push(0);
msg.extend_from_slice(k.as_bytes());
msg.push(0);
msg.extend_from_slice(onion.as_bytes());
msg.push(0);
msg.extend_from_slice(spk.as_bytes());
msg
}
fn sign_msg_v1(k: &str, onion: &str, spk: &str) -> Vec<u8> {
let mut msg = Vec::with_capacity(k.len() + onion.len() + spk.len()); let mut msg = Vec::with_capacity(k.len() + onion.len() + spk.len());
msg.extend_from_slice(k.as_bytes()); msg.extend_from_slice(k.as_bytes());
msg.extend_from_slice(onion.as_bytes()); msg.extend_from_slice(onion.as_bytes());
@ -118,7 +168,7 @@ fn to_hex(bytes: &[u8]) -> String {
} }
fn from_hex(s: &str) -> Result<Vec<u8>> { fn from_hex(s: &str) -> Result<Vec<u8>> {
if s.is_empty() || !s.len().is_multiple_of(2) { if s.len() > HEX_SIG || s.is_empty() || !s.len().is_multiple_of(2) {
return Err(Error("invalid hex".into())); return Err(Error("invalid hex".into()));
} }
if !s.bytes().all(|c| c.is_ascii_hexdigit()) { if !s.bytes().all(|c| c.is_ascii_hexdigit()) {
@ -129,3 +179,25 @@ fn from_hex(s: &str) -> Result<Vec<u8>> {
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| Error("invalid hex".into()))) .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| Error("invalid hex".into())))
.collect() .collect()
} }
#[cfg(test)]
mod tests {
use super::*;
fn v3() -> String {
format!("{}.onion", "a".repeat(56))
}
#[test]
fn well_formed_v1_concat_still_decodes() {
let sk = SigningKey::from_bytes(&[7u8; 32]);
let k = to_hex(&sk.verifying_key().to_bytes());
let onion = v3();
let spk = to_hex(&[9u8; 32]);
let sig = to_hex(&sk.sign(&sign_msg_v1(&k, &onion, &spk)).to_bytes());
let raw = format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}");
let p = decode(&raw).expect("legacy concat");
assert_eq!(p.onion, onion);
assert_eq!(p.signed_prekey, vec![9u8; 32]);
}
}

View file

@ -270,12 +270,13 @@ impl Store {
match row { match row {
Some((salt, wrapped)) => { Some((salt, wrapped)) => {
let wrap_key = crate::backup::kdf(passphrase, &salt)?; let wrap_key = crate::backup::kdf(passphrase, &salt)?;
let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped) let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped, b"")
.map_err(|_| Error("wrong passphrase".into()))?; .map_err(|_| Error("wrong passphrase".into()))?;
if raw.len() != 32 { if raw.len() != 32 {
return Err(Error("wrapped message key length".into())); return Err(Error("wrapped message key length".into()));
} }
self.msg_key.copy_from_slice(&raw); self.msg_key.copy_from_slice(&raw);
self.rewrap_empty_aad_messages()?;
Ok(()) Ok(())
} }
None => { None => {
@ -284,7 +285,7 @@ impl Store {
let mut salt = [0u8; 16]; let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt); OsRng.fill_bytes(&mut salt);
let wrap_key = crate::backup::kdf(passphrase, &salt)?; let wrap_key = crate::backup::kdf(passphrase, &salt)?;
let wrapped = crate::backup::aead_encrypt(&wrap_key, &data_key)?; let wrapped = crate::backup::aead_encrypt(&wrap_key, &data_key, b"")?;
self.conn.execute( self.conn.execute(
"INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)", "INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)",
params![salt.as_slice(), wrapped], params![salt.as_slice(), wrapped],
@ -297,17 +298,59 @@ impl Store {
} }
fn reencrypt_legacy_messages(&self) -> Result<()> { fn reencrypt_legacy_messages(&self) -> Result<()> {
let mut stmt = self.conn.prepare("SELECT id, plaintext FROM messages")?; let mut stmt = self
let rows: Vec<(i64, Vec<u8>)> = stmt .conn
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? .prepare("SELECT id, friend_id, dir, plaintext FROM messages")?;
let rows: Vec<(i64, i64, String, Vec<u8>)> = stmt
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?
.collect::<std::result::Result<_, _>>()?; .collect::<std::result::Result<_, _>>()?;
drop(stmt); drop(stmt);
if rows.is_empty() { if rows.is_empty() {
return Ok(()); return Ok(());
} }
let tx = self.conn.unchecked_transaction()?; let tx = self.conn.unchecked_transaction()?;
for (id, plain) in rows { for (id, friend_id, dir, plain) in rows {
let blob = crate::backup::aead_encrypt(&self.msg_key, &plain)?; let aad = message_aad(friend_id, &dir, id);
let blob = crate::backup::aead_encrypt(&self.msg_key, &plain, &aad)?;
tx.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![blob, id],
)?;
}
tx.commit()?;
let _ = self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)");
Ok(())
}
/// One-shot: empty-AAD v0.2 blobs → row-bound AAD. list_messages never falls back.
fn rewrap_empty_aad_messages(&self) -> Result<()> {
let mut stmt = self
.conn
.prepare("SELECT id, friend_id, dir, plaintext FROM messages")?;
let rows: Vec<(i64, i64, String, Vec<u8>)> = stmt
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?
.collect::<std::result::Result<_, _>>()?;
drop(stmt);
let mut updates = Vec::new();
for (id, friend_id, dir, blob) in rows {
let aad = message_aad(friend_id, &dir, id);
if crate::backup::aead_decrypt(&self.msg_key, &blob, &aad).is_ok() {
continue;
}
if let Ok(pt) = crate::backup::aead_decrypt(&self.msg_key, &blob, b"") {
let new_blob = crate::backup::aead_encrypt(&self.msg_key, &pt, &aad)?;
updates.push((id, new_blob));
}
}
if updates.is_empty() {
return Ok(());
}
let tx = self.conn.unchecked_transaction()?;
for (id, blob) in updates {
tx.execute( tx.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2", "UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![blob, id], params![blob, id],
@ -385,6 +428,23 @@ impl Store {
.map_err(Into::into) .map_err(Into::into)
} }
/// Display fingerprint of our own identity key (identity = pubkey).
pub fn self_fingerprint(&self) -> Result<String> {
Ok(fingerprint(&self.self_identity()?.identity_pk))
}
/// Local petname only. Never part of the wire protocol.
pub fn set_petname(&self, pubkey: &[u8], petname: Option<&str>) -> Result<()> {
let n = self.conn.execute(
"UPDATE friends SET petname = ?1 WHERE pubkey = ?2",
params![petname, pubkey],
)?;
if n == 0 {
return Err(Error("friend not found".into()));
}
Ok(())
}
pub fn upsert_friend(&self, pubkey: &[u8], onion: &str, petname: Option<&str>) -> Result<()> { pub fn upsert_friend(&self, pubkey: &[u8], onion: &str, petname: Option<&str>) -> Result<()> {
let now = unix_now(); let now = unix_now();
let fp = fingerprint(pubkey); let fp = fingerprint(pubkey);
@ -646,28 +706,42 @@ impl Store {
|row| row.get(0), |row| row.get(0),
) )
.map_err(|_| Error("friend not found".into()))?; .map_err(|_| Error("friend not found".into()))?;
let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext)?; let tx = self.conn.unchecked_transaction()?;
self.conn.execute( tx.execute(
"INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, x'', ?3)",
params![friend_id, dir, blob, unix_now()], params![friend_id, dir, unix_now()],
)?; )?;
let row_id = tx.last_insert_rowid();
let aad = message_aad(friend_id, dir, row_id);
let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext, &aad)?;
tx.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![blob, row_id],
)?;
tx.commit()?;
Ok(()) Ok(())
} }
pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>> { pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT m.dir, m.plaintext FROM messages m "SELECT m.id, m.friend_id, m.dir, m.plaintext FROM messages m
JOIN friends f ON f.id = m.friend_id JOIN friends f ON f.id = m.friend_id
WHERE f.pubkey = ?1 WHERE f.pubkey = ?1
ORDER BY m.id", ORDER BY m.id",
)?; )?;
let rows = stmt.query_map(params![friend_pk], |row| { let rows = stmt.query_map(params![friend_pk], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?)) Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Vec<u8>>(3)?,
))
})?; })?;
let mut out = Vec::new(); let mut out = Vec::new();
for row in rows { for row in rows {
let (dir, blob) = row?; let (id, friend_id, dir, blob) = row?;
let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob) let aad = message_aad(friend_id, &dir, id);
let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob, &aad)
.map_err(|_| Error("message decrypt failed".into()))?; .map_err(|_| Error("message decrypt failed".into()))?;
out.push(Message { dir, plaintext }); out.push(Message { dir, plaintext });
} }
@ -758,6 +832,17 @@ fn mkdir_700(path: &Path) -> Result<()> {
Ok(()) Ok(())
} }
/// AAD: `owmsg1` || friend_id_le64 || dir || 0x00 || row_id_le64
fn message_aad(friend_id: i64, dir: &str, row_id: i64) -> Vec<u8> {
let mut aad = Vec::with_capacity(6 + 8 + dir.len() + 1 + 8);
aad.extend_from_slice(b"owmsg1");
aad.extend_from_slice(&friend_id.to_le_bytes());
aad.extend_from_slice(dir.as_bytes());
aad.push(0);
aad.extend_from_slice(&row_id.to_le_bytes());
aad
}
fn unix_now() -> i64 { fn unix_now() -> i64 {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@ -773,6 +858,148 @@ fn fingerprint(pubkey: &[u8]) -> String {
mod at_rest { mod at_rest {
use super::*; use super::*;
#[test]
fn swapped_ciphertext_does_not_show_other_friends_body() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open_at_with_passphrase(dir.path(), "aad-pass").unwrap();
let alice = [1u8; 32];
let bob = [2u8; 32];
store.upsert_friend(&alice, "a.onion", None).unwrap();
store.upsert_friend(&bob, "b.onion", None).unwrap();
store
.append_message(&alice, "out", b"secret-for-alice")
.unwrap();
store
.append_message(&bob, "out", b"secret-for-bob")
.unwrap();
let blobs: Vec<(i64, Vec<u8>)> = {
let mut stmt = store
.conn
.prepare("SELECT id, plaintext FROM messages ORDER BY id")
.unwrap();
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
assert_eq!(blobs.len(), 2);
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![&blobs[1].1, blobs[0].0],
)
.unwrap();
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![&blobs[0].1, blobs[1].0],
)
.unwrap();
if let Ok(msgs) = store.list_messages(&alice) {
assert!(
msgs.iter()
.all(|m| m.plaintext.as_slice() != b"secret-for-bob"),
"alice saw bob's body after ciphertext swap"
);
}
if let Ok(msgs) = store.list_messages(&bob) {
assert!(
msgs.iter()
.all(|m| m.plaintext.as_slice() != b"secret-for-alice"),
"bob saw alice's body after ciphertext swap"
);
}
}
#[test]
fn empty_aad_rows_rewrap_on_unlock_then_swap_fails() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open_at_with_passphrase(dir.path(), "rewrap-pass").unwrap();
let alice = [1u8; 32];
let bob = [2u8; 32];
store.upsert_friend(&alice, "a.onion", None).unwrap();
store.upsert_friend(&bob, "b.onion", None).unwrap();
store
.append_message(&alice, "out", b"secret-for-alice")
.unwrap();
store
.append_message(&bob, "out", b"secret-for-bob")
.unwrap();
let ids: Vec<i64> = {
let mut stmt = store
.conn
.prepare("SELECT id FROM messages ORDER BY id")
.unwrap();
stmt.query_map([], |r| r.get(0))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
let a_blob = crate::backup::aead_encrypt(&store.msg_key, b"secret-for-alice", b"").unwrap();
let b_blob = crate::backup::aead_encrypt(&store.msg_key, b"secret-for-bob", b"").unwrap();
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![a_blob, ids[0]],
)
.unwrap();
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![b_blob, ids[1]],
)
.unwrap();
drop(store);
let store = Store::open_at_with_passphrase(dir.path(), "rewrap-pass").unwrap();
assert_eq!(
store.list_messages(&alice).unwrap()[0].plaintext,
b"secret-for-alice"
);
assert_eq!(
store.list_messages(&bob).unwrap()[0].plaintext,
b"secret-for-bob"
);
let blobs: Vec<(i64, Vec<u8>)> = {
let mut stmt = store
.conn
.prepare("SELECT id, plaintext FROM messages ORDER BY id")
.unwrap();
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![&blobs[1].1, blobs[0].0],
)
.unwrap();
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![&blobs[0].1, blobs[1].0],
)
.unwrap();
if let Ok(msgs) = store.list_messages(&alice) {
assert!(
msgs.iter()
.all(|m| m.plaintext.as_slice() != b"secret-for-bob"),
"alice saw bob's body after post-rewrap swap"
);
}
}
#[test] #[test]
fn first_unlock_reencrypts_legacy_plaintext_rows() { fn first_unlock_reencrypts_legacy_plaintext_rows() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View file

@ -140,12 +140,11 @@ impl Wallet {
} }
pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address: &str) -> bool { pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address: &str) -> bool {
rows.iter().any(|r| { if txid.is_empty() || amount.is_empty() || address.is_empty() {
r.txid == txid return false;
|| (!address.is_empty() }
&& r.address == address rows.iter()
&& (amount.is_empty() || r.amount == amount)) .any(|r| r.txid == txid && r.amount == amount && r.address == address)
})
} }
fn parse_http_url(url: &str) -> Result<Endpoint> { fn parse_http_url(url: &str) -> Result<Endpoint> {

View file

@ -2,8 +2,8 @@
use std::sync::{Mutex, MutexGuard}; use std::sync::{Mutex, MutexGuard};
use onionwire::Store;
use onionwire::qr; use onionwire::qr;
use onionwire::Store;
static ENV_LOCK: Mutex<()> = Mutex::new(()); static ENV_LOCK: Mutex<()> = Mutex::new(());
@ -15,7 +15,7 @@ struct TempHome {
impl TempHome { impl TempHome {
fn new() -> Self { fn new() -> Self {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let guard = ENV_LOCK.lock().expect("env lock"); let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { unsafe {
std::env::set_var("ONIONWIRE_HOME", dir.path()); std::env::set_var("ONIONWIRE_HOME", dir.path());
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test"); std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
@ -37,7 +37,37 @@ impl Drop for TempHome {
} }
fn v3onion(tag: &str) -> String { fn v3onion(tag: &str) -> String {
format!("{tag}.onion") let mut addr = vec![b'a'; 56];
let bytes: Vec<u8> = tag
.bytes()
.map(|b| match b {
b'a'..=b'z' | b'2'..=b'7' => b,
_ => b'a',
})
.collect();
let n = bytes.len().min(56);
addr[..n].copy_from_slice(&bytes[..n]);
format!("{}.onion", String::from_utf8(addr).expect("base32"))
}
fn invite_fields(raw: &str) -> (String, String, String, String) {
let rest = raw.strip_prefix("onionwire:v1:").expect("prefix");
let mut k = String::new();
let mut o = String::new();
let mut spk = String::new();
let mut sig = String::new();
for part in rest.split(':') {
if let Some(v) = part.strip_prefix("k=") {
k = v.to_string();
} else if let Some(v) = part.strip_prefix("o=") {
o = v.to_string();
} else if let Some(v) = part.strip_prefix("spk=") {
spk = v.to_string();
} else if let Some(v) = part.strip_prefix("sig=") {
sig = v.to_string();
}
}
(k, o, spk, sig)
} }
#[test] #[test]
@ -119,3 +149,73 @@ fn unknown_k_decode_does_not_insert() {
let _p = qr::decode(&raw).unwrap(); let _p = qr::decode(&raw).unwrap();
assert_eq!(store.friend_count().unwrap(), 0); assert_eq!(store.friend_count().unwrap(), 0);
} }
#[test]
fn shifted_o_spk_is_err() {
let _home = TempHome::new();
let store = Store::open().expect("open");
let me = store.self_identity().expect("self");
let onion = v3onion("honest");
let spk = [0xab; 32];
let raw = qr::encode(&me.identity_sk, &onion, &spk).expect("encode");
let (k, o, spk_hex, sig) = invite_fields(&raw);
assert_eq!(spk_hex.len(), 64);
let mutant = format!(
"onionwire:v1:k={k}:o={o}{}:spk={}:sig={sig}",
&spk_hex[..8],
&spk_hex[8..]
);
assert!(
qr::decode(&mutant).is_err(),
"shifted o/spk must not verify"
);
}
#[test]
fn encode_rejects_spk_not_32() {
let _home = TempHome::new();
let store = Store::open().expect("open");
let me = store.self_identity().expect("self");
let onion = v3onion("spk");
assert!(qr::encode(&me.identity_sk, &onion, &[1u8; 31]).is_err());
assert!(qr::encode(&me.identity_sk, &onion, &[1u8; 33]).is_err());
assert!(qr::encode(&me.identity_sk, &onion, &[]).is_err());
}
#[test]
fn encode_rejects_garbage_onion() {
let _home = TempHome::new();
let store = Store::open().expect("open");
let me = store.self_identity().expect("self");
let spk = [2u8; 32];
assert!(qr::encode(&me.identity_sk, "not-an-onion", &spk).is_err());
assert!(qr::encode(&me.identity_sk, "alice.onion", &spk).is_err());
assert!(qr::encode(&me.identity_sk, &format!("{}.onion", "A".repeat(56)), &spk).is_err());
}
#[test]
fn decode_rejects_spk_len_and_garbage_onion() {
let _home = TempHome::new();
let store = Store::open().expect("open");
let me = store.self_identity().expect("self");
let raw = qr::encode(&me.identity_sk, &v3onion("ok"), &[3u8; 32]).expect("encode");
let (k, o, spk, sig) = invite_fields(&raw);
let short_spk = format!("onionwire:v1:k={k}:o={o}:spk={}:sig={sig}", &spk[..62]);
assert!(qr::decode(&short_spk).is_err());
let garbage_o = format!("onionwire:v1:k={k}:o=nope.onion:spk={spk}:sig={sig}");
assert!(qr::decode(&garbage_o).is_err());
}
#[test]
fn huge_hex_is_err_quickly() {
let huge = format!(
"onionwire:v1:k={}:o=x:spk=yy:sig=zz",
"aa".repeat(1024 * 1024)
);
let t = std::time::Instant::now();
assert!(qr::decode(&huge).is_err());
assert!(
t.elapsed() < std::time::Duration::from_millis(250),
"decode must fail closed before a multi-MB alloc"
);
}

View file

@ -71,7 +71,7 @@ fn chrome_renders_at_80x24_and_120x40() {
use ratatui::Terminal; use ratatui::Terminal;
let fp = "abcdef0123456789"; let fp = "abcdef0123456789";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
for (w, h) in [(80u16, 24u16), (120, 40)] { for (w, h) in [(80u16, 24u16), (120, 40)] {
let backend = TestBackend::new(w, h); let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("terminal"); let mut terminal = Terminal::new(backend).expect("terminal");
@ -113,7 +113,7 @@ fn chrome_renders_at_80x24_and_120x40() {
#[test] #[test]
fn main_footer_keeps_help_at_80_and_120() { fn main_footer_keeps_help_at_80_and_120() {
let fp = "abcdef0123456789deadbeef"; let fp = "abcdef0123456789deadbeef";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
let hints = main_footer_hints(); let hints = main_footer_hints();
assert!(hints.contains("? help")); assert!(hints.contains("? help"));
for w in [80u16, 120] { for w in [80u16, 120] {
@ -170,7 +170,7 @@ fn share_screen_shows_invite_not_qr_at_80x24() {
use ratatui::Terminal; use ratatui::Terminal;
let sk = [7u8; 32]; let sk = [7u8; 32];
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
let payload = qr::encode(&sk, onion, &[1u8; 32]).expect("encode"); let payload = qr::encode(&sk, onion, &[1u8; 32]).expect("encode");
assert!(payload.starts_with("onionwire:v1:")); assert!(payload.starts_with("onionwire:v1:"));

View file

@ -1,9 +1,25 @@
//! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod. //! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod.
use onionwire::wallet::{self, Wallet}; use ed25519_dalek::SigningKey;
use onionwire::pay;
use onionwire::wallet::{self, TransferRow, Wallet};
use onionwire::{PaymentWrite, Store};
use rand::rngs::OsRng;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener; use tokio::net::TcpListener;
fn row(txid: &str, amount: &str, address: &str) -> TransferRow {
TransferRow {
txid: txid.into(),
amount: amount.into(),
address: address.into(),
}
}
fn xmr_addr() -> String {
format!("8{}", "B".repeat(94))
}
fn json_rpc_ok(result: &str) -> String { fn json_rpc_ok(result: &str) -> String {
let body = format!(r#"{{"jsonrpc":"2.0","id":"0","result":{result}}}"#); let body = format!(r#"{{"jsonrpc":"2.0","id":"0","result":{result}}}"#);
format!( format!(
@ -96,3 +112,71 @@ async fn mock_get_transfers_matches_txid() {
)); ));
assert!(!wallet::transfers_match(&rows, "nope", "1", "nope")); assert!(!wallet::transfers_match(&rows, "nope", "1", "nope"));
} }
#[test]
fn transfers_match_txid_only_wrong_amount_or_addr_is_false() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(!wallet::transfers_match(&rows, "deadbeef", "99", &addr));
let other = format!("8{}", "C".repeat(94));
assert!(!wallet::transfers_match(&rows, "deadbeef", "5", &other));
}
#[test]
fn transfers_match_address_and_amount_wrong_txid_is_false() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(!wallet::transfers_match(&rows, "cafebabe", "5", &addr));
}
#[test]
fn transfers_match_honest_triple_is_true() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(wallet::transfers_match(&rows, "deadbeef", "5", &addr));
}
#[test]
fn transfers_match_empty_field_is_false() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(!wallet::transfers_match(&rows, "", "5", &addr));
assert!(!wallet::transfers_match(&rows, "deadbeef", "", &addr));
assert!(!wallet::transfers_match(&rows, "deadbeef", "5", ""));
let empty = [row("", "", "")];
assert!(!wallet::transfers_match(&empty, "", "", ""));
}
/// ingest_receipt inserts verified=0, then mark_verified iff transfers_match.
/// A Noise-signed rcp that cites an unrelated wallet row must stay unverified.
#[test]
fn ingest_signed_receipt_mismatched_wallet_history_stays_unverified() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
let sk = SigningKey::generate(&mut OsRng);
let pk = sk.verifying_key().to_bytes();
store.upsert_friend(&pk, "peer.onion", None).unwrap();
let addr = xmr_addr();
let rcp = pay::sign_receipt(&sk.to_bytes(), "unrelated", "5", &addr, 1).unwrap();
assert!(pay::verify_receipt(&pk, &rcp));
let id = store
.insert_payment(
&pk,
PaymentWrite {
dir: "in",
kind: "receipt",
amount_atomic: &rcp.amount_atomic,
address: &rcp.address,
memo: "",
txid: Some(&rcp.txid),
verified: false,
},
)
.unwrap();
let history = [row("unrelated", "99", &format!("8{}", "C".repeat(94)))];
if wallet::transfers_match(&history, &rcp.txid, &rcp.amount_atomic, &rcp.address) {
store.mark_verified(id).unwrap();
}
let rows = store.list_payments(&pk).unwrap();
assert!(!rows[0].verified);
}