Compare commits
7
Commits
main
..
73a32cb897
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73a32cb897 | ||
|
|
841a3a8372 | ||
|
|
592114bfc9 | ||
|
|
d6d802c3c7 | ||
|
|
861e6a329b | ||
|
|
7651d491ac | ||
|
|
995b29f10d |
No files matched your search
@@ -1,5 +1 @@
|
|||||||
server/target/
|
server/target/
|
||||||
|
|
||||||
# Gradle build outputs for the app half.
|
|
||||||
app/build/
|
|
||||||
.gradle/
|
|
||||||
@@ -94,70 +94,21 @@ time the sharing paid rather than merely being argued for.
|
|||||||
|
|
||||||
## What is here
|
## What is here
|
||||||
|
|
||||||
`server/` — the `wg-app-link` crate. Six modules, each extracted only after
|
`server/` — the `wg-app-link` crate. Five modules, each extracted only after
|
||||||
diffing the two copies and finding nothing but a name between them.
|
diffing the two copies and finding nothing but a name between them.
|
||||||
|
|
||||||
- **`netif`** — `wg_address()`, which fails closed when the tunnel is down, and `local_addresses()` for the certificate's SANs. The product name is a parameter so the failure reads as advice rather than as a library complaining. Both split the *lookup* from the *decision*, so the failure path can be tested on a machine that has a tunnel — every machine this runs on does.
|
- **`netif`** — `wg_address()`, which fails closed when the tunnel is down, and `local_addresses()` for the certificate's SANs. The product name is a parameter so the failure reads as advice rather than as a library complaining. Both split the *lookup* from the *decision*, so the failure path can be tested on a machine that has a tunnel — every machine this runs on does.
|
||||||
- **`enroll`** — token generation, hex-SHA-256 storage, constant-time comparison, the `<scheme>://enroll?…` URI, and the terminal QR. The URI scheme is the parameter, because it is what routes a scan back to the right app.
|
- **`enroll`** — token generation, hex-SHA-256 storage, constant-time comparison, the `<scheme>://enroll?…` URI, and the terminal QR. The URI scheme is the parameter, because it is what routes a scan back to the right app.
|
||||||
- **`certs`** — the CA generated once and never replaced, the leaf reissued every start. `product` names the organisation and common name and is the whole of what is per-project. Shared because being written twice is worst here: a trust anchor built two ways can be built differently two ways, and the difference surfaces as an opaque handshake failure on a phone.
|
- **`certs`** — the CA generated once and never replaced, the leaf reissued every start. `product` names the organisation and common name and is the whole of what is per-project. Shared because being written twice is worst here: a trust anchor built two ways can be built differently two ways, and the difference surfaces as an opaque handshake failure on a phone.
|
||||||
- **`format`** — the two RON house rules, plus `write`, which renders a value and replaces a file with it atomically and owner-only. This was byte-identical in both projects, which made it the clearest thing in the evidence table and the easiest deletion.
|
- **`format`** — the two RON house rules. This was byte-identical in both projects, which made it the clearest thing in the evidence table and the easiest deletion.
|
||||||
- **`private`** — owner-only files and directories, taken from ai-app's version, with an `append_file` alongside `create_file` because a transcript must never be truncated by being opened.
|
- **`private`** — owner-only files and directories, taken from ai-app's version, with an `append_file` alongside `create_file` because a transcript must never be truncated by being opened.
|
||||||
- **`xdg`** — where each project's state lives: `config_home(product)` and `data_home(product)`, resolving the XDG variable, ignoring it unless absolute, and falling back under `$HOME`. Shared because the *reason* is shared and is not obvious from the code — the repo is a mount that resolves at different absolute paths on each side, so config inside it records paths that work on only one, and a CA private key inside it would let the untrusted side mint a leaf the pinned app trusts.
|
|
||||||
|
|
||||||
`app/` — the Android library, `com.example.wgapplink`. The same test applied
|
|
||||||
to Kotlin: the four files appeared in both apps and differed only in
|
|
||||||
comments and a product name.
|
|
||||||
|
|
||||||
- **`PinnedTls`** — trusts one CA and not the system store, so a genuine certificate for another host is refused as firmly as a self-signed one. The PEM is constructed with, not read, because each app generates its own at build time from the machine doing the build.
|
|
||||||
- **`ServerStore`** — the enrollment: preferences, the `<scheme>://enroll?…` URI, and the token sealed under an Android Keystore key. Two parameters, both per-app and both load-bearing — see the warning below.
|
|
||||||
- **`EnrollmentScanActivity`** — zxing's capture activity with the 10% framing inset and the laser decorations removed, so framing is never the user's problem.
|
|
||||||
- **`localNetworkAllowed`** — whether `ACCESS_LOCAL_NETWORK` was granted. Android 17 made it mandatory and a denial is invisible at the socket, so without asking, a blocked app and an unreachable server produce the same timeout.
|
|
||||||
|
|
||||||
**Adopting the app half takes two lines and one catalog entry.** In
|
|
||||||
`settings.gradle.kts`:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
include(":link")
|
|
||||||
project(":link").projectDir = file("../wg-app-link/app")
|
|
||||||
```
|
|
||||||
|
|
||||||
and in the version catalog, because a subproject resolves plugin versions
|
|
||||||
from the build including it rather than from its own:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
|
||||||
```
|
|
||||||
|
|
||||||
It also needs `androidx-core-ktx` and `zxing-embedded` in that catalog, and
|
|
||||||
`alias(libs.plugins.androidLibrary) apply false` in the root build file.
|
|
||||||
Consumed as a subproject rather than a published artifact so the two stay
|
|
||||||
locked to whatever commit the submodule points at — the same arrangement
|
|
||||||
the Rust half uses with a path dependency.
|
|
||||||
|
|
||||||
> **The Keystore alias is persisted. Carry the existing value over exactly.**
|
|
||||||
> `ServerStore(scheme, keyAlias)` takes both because both are per-app, but
|
|
||||||
> they fail differently. A wrong scheme means a scanned QR is ignored, which
|
|
||||||
> is visible immediately. A wrong `keyAlias` means the app can no longer
|
|
||||||
> unseal the token it already stored, so an enrolled phone silently reads as
|
|
||||||
> not enrolled with nothing on screen to say why. ai-app's is
|
|
||||||
> `aiapp-token-key`; dev-updater's is `dev-updater-token-key`.
|
|
||||||
|
|
||||||
**Not moved: the `PinnedCaCertificate.kt` generator.** It is ~40 lines of
|
|
||||||
Gradle in each app's build file, and all three things it varies — the
|
|
||||||
environment variable, the certificate path, and the package the constant is
|
|
||||||
emitted into — are per-app, so sharing it means parameterising the whole of
|
|
||||||
it and introducing a composite build that neither project has today. The
|
|
||||||
one bug it has had is fixed identically in both copies: a PEM constant must
|
|
||||||
start at the opening quotes, or `CertificateFactory` loses its `-----BEGIN`
|
|
||||||
preamble sniff, tries DER, and fails at runtime with an ASN.1 decode error
|
|
||||||
nowhere near the generator. Worth revisiting if it ever needs a second fix.
|
|
||||||
|
|
||||||
## What should follow, and what should not
|
## What should follow, and what should not
|
||||||
|
|
||||||
**Should follow, in this order.** Each is already near-identical:
|
**Should follow, in this order.** Each is already near-identical:
|
||||||
|
|
||||||
1. ~~The Kotlin `EnrollmentScanActivity`, `PinnedCert`, and the enrollment/Keystore half of `ServerConfig`.~~ Done — see `app/` above. It paid as predicted: ai-app's `ServerConfig` had gained `localNetworkAllowed` and the KTX `edit` block while dev-updater's had not, so the two had drifted in both directions exactly as the evidence suggested.
|
1. The Kotlin `EnrollmentScanActivity`, `PinnedCert`, and the enrollment/Keystore half of `ServerConfig`, as an Android library module. This is where the sharing pays most, because it is where the two copies have drifted furthest apart in *both* directions — and `applyPinnedTls` is security-critical and identical, which is the same argument `certs` won on.
|
||||||
2. ~~Atomic owner-only config save.~~ Done — `format::write`, since it is the RON house rules and the owner-only rules used together. The subtlety it now states once is that the temp file is *not always new*: a save killed partway leaves one behind, and reopening it keeps whatever mode it had, which is then renamed over the file holding the enrolled token hashes. There is a test for exactly that, because it cannot happen on a machine where nothing has ever crashed mid-save.
|
2. Atomic owner-only config save. Both do temp-file-then-rename with the mode set before the rename; only the schema differs.
|
||||||
|
|
||||||
**Should not.** Naming these is the point of the exercise:
|
**Should not.** Naming these is the point of the exercise:
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
// The app half of the link, as a library both phones' apps depend on.
|
|
||||||
//
|
|
||||||
// Consumed as a *subproject* of each app's build rather than as a published
|
|
||||||
// artifact or a composite build, so the two stay version-locked to whatever
|
|
||||||
// commit of this repo the consuming project's submodule points at -- the
|
|
||||||
// same arrangement the Rust half uses with a path dependency. Each app adds
|
|
||||||
// two lines to its `settings.gradle.kts`:
|
|
||||||
//
|
|
||||||
// include(":link")
|
|
||||||
// project(":link").projectDir = file("../wg-app-link/app")
|
|
||||||
//
|
|
||||||
// and one entry to its version catalog, since a subproject resolves plugin
|
|
||||||
// versions from the build including it:
|
|
||||||
//
|
|
||||||
// androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
|
||||||
//
|
|
||||||
// Deliberately no Compose. Nothing here draws anything -- the screens that
|
|
||||||
// use it are each app's own, because they are where the two products
|
|
||||||
// actually differ.
|
|
||||||
plugins {
|
|
||||||
alias(libs.plugins.androidLibrary)
|
|
||||||
alias(libs.plugins.ktfmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
ktfmt { kotlinLangStyle() }
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.example.wgapplink"
|
|
||||||
compileSdk = 37
|
|
||||||
|
|
||||||
// minSdk matches the lower of the two consumers rather than this
|
|
||||||
// library's own preference: a library that raised it would silently
|
|
||||||
// raise theirs. No desugaring here for the same reason -- nothing in
|
|
||||||
// this module uses java.time, and requiring it would impose a build
|
|
||||||
// setting on anyone who depends on it.
|
|
||||||
defaultConfig { minSdk = 24 }
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
|
||||||
targetCompatibility = JavaVersion.VERSION_21
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
// SharedPreferences.edit, and the KTX extensions this code calls
|
|
||||||
// directly rather than inheriting transitively.
|
|
||||||
implementation(libs.androidx.core.ktx)
|
|
||||||
// The capture activity EnrollmentScanActivity subclasses. `api` rather
|
|
||||||
// than `implementation`: the subclass is part of this module's public
|
|
||||||
// surface, so a consumer declaring it in its manifest needs the type.
|
|
||||||
api(libs.zxing.embedded)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Nothing is declared here. EnrollmentScanActivity is registered by each
|
|
||||||
consuming app's own manifest, because the activity's label, theme and
|
|
||||||
parent are the app's business and merging a fixed one would take that
|
|
||||||
choice away. -->
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package com.example.wgapplink
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import com.journeyapps.barcodescanner.CaptureActivity
|
|
||||||
import com.journeyapps.barcodescanner.DecoratedBarcodeView
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The screen behind an app's "Scan QR code" button: zxing-android-embedded's capture activity with
|
|
||||||
* two of its defaults taken off, both because they put work on the person holding the phone.
|
|
||||||
*
|
|
||||||
* - **It decodes only the framing rectangle**, which `CameraPreview.marginFraction` insets by 10%
|
|
||||||
* from every edge and `DecoderThread` crops each frame to before the decoder sees it. A code that
|
|
||||||
* fills the viewfinder keeps decoding -- measured, not assumed -- but it does so having spent its
|
|
||||||
* quiet zone and margin for error on the crop, and anything further out is simply not looked at.
|
|
||||||
* Decoding the whole preview costs nothing and means the framing is never the user's problem.
|
|
||||||
* - **It decorates the preview** with a red laser line and the dots the detector scatters wherever
|
|
||||||
* it finds a candidate pattern. That is the library's house style; a plain preview is ours.
|
|
||||||
*
|
|
||||||
* Orientation is left to the sensor rather than pinned to landscape as the library's own manifest
|
|
||||||
* entry pins it, so the phone can be held whichever way the code is in front of it.
|
|
||||||
*/
|
|
||||||
class EnrollmentScanActivity : CaptureActivity() {
|
|
||||||
override fun initializeContent(): DecoratedBarcodeView {
|
|
||||||
val view = super.initializeContent()
|
|
||||||
view.barcodeView.marginFraction = 0.0
|
|
||||||
view.viewFinder.visibility = View.GONE
|
|
||||||
view.statusView.visibility = View.GONE
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package com.example.wgapplink
|
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.security.KeyStore
|
|
||||||
import java.security.cert.CertificateFactory
|
|
||||||
import javax.net.ssl.HttpsURLConnection
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocketFactory
|
|
||||||
import javax.net.ssl.TrustManagerFactory
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TLS that trusts one CA and nothing else -- not the device's system trust store, so a genuine
|
|
||||||
* CA-issued certificate for some other host is refused just as firmly as a self-signed one.
|
|
||||||
*
|
|
||||||
* The certificate is passed in rather than read from anywhere, because each app generates its own
|
|
||||||
* at build time from the CA on the machine doing the build. That is what makes the trust anchor
|
|
||||||
* follow the build: an APK built on the backend host trusts that host, and one built in a dev VM
|
|
||||||
* trusts that VM's throwaway CA and is good only for its emulator. Neither app should ever have a
|
|
||||||
* second anchor to get wrong, which is also why enrollment carries no CA -- photographing the
|
|
||||||
* terminal leaks only the rotatable token.
|
|
||||||
*
|
|
||||||
* Hold one of these per app and reuse it: the [socketFactory] is built lazily and then kept, since
|
|
||||||
* every reconnect would otherwise redo the KeyStore and TrustManager setup from scratch.
|
|
||||||
*/
|
|
||||||
class PinnedTls(private val caPem: String) {
|
|
||||||
|
|
||||||
val socketFactory: SSLSocketFactory by lazy {
|
|
||||||
// Trimmed because CertificateFactory only recognises PEM when the
|
|
||||||
// "-----BEGIN" preamble is the very first thing it sees; surrounding
|
|
||||||
// whitespace sends it down the DER path instead, which fails at
|
|
||||||
// runtime with an ASN.1 decode error nowhere near the cause.
|
|
||||||
val caCert =
|
|
||||||
CertificateFactory.getInstance("X.509")
|
|
||||||
.generateCertificate(ByteArrayInputStream(caPem.trim().encodeToByteArray()))
|
|
||||||
val keyStore =
|
|
||||||
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
|
||||||
load(null, null)
|
|
||||||
// The alias names an entry in a KeyStore that exists only in
|
|
||||||
// memory for the length of this block, so it is arbitrary and
|
|
||||||
// deliberately not per-app -- nothing reads it back.
|
|
||||||
setCertificateEntry(PINNED_CA_ALIAS, caCert)
|
|
||||||
}
|
|
||||||
val trustManager =
|
|
||||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
|
||||||
.apply { init(keyStore) }
|
|
||||||
.trustManagers
|
|
||||||
.filterIsInstance<X509TrustManager>()
|
|
||||||
.first()
|
|
||||||
SSLContext.getInstance("TLS")
|
|
||||||
.apply { init(null, arrayOf(trustManager), null) }
|
|
||||||
.socketFactory
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pins [connection], which must be every connection the app makes -- there is no unpinned path
|
|
||||||
* in either project, and adding one would quietly undo the whole arrangement.
|
|
||||||
*
|
|
||||||
* A plain HTTP connection is left alone rather than refused, since dev-updater serves its
|
|
||||||
* bootstrap download over one deliberately.
|
|
||||||
*/
|
|
||||||
fun applyTo(connection: HttpURLConnection) {
|
|
||||||
if (connection is HttpsURLConnection) {
|
|
||||||
connection.sslSocketFactory = socketFactory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val PINNED_CA_ALIAS = "pinned-ca"
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package com.example.wgapplink
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
|
||||||
import android.security.keystore.KeyProperties
|
|
||||||
import android.util.Base64
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import java.security.KeyStore
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.KeyGenerator
|
|
||||||
import javax.crypto.SecretKey
|
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a backend is and how to authenticate to it. Absent until the phone is enrolled, which
|
|
||||||
* happens by scanning the QR the server prints to its terminal.
|
|
||||||
*/
|
|
||||||
data class ServerSettings(val host: String, val port: Int, val token: String) {
|
|
||||||
val baseUrl: String
|
|
||||||
get() = "https://$host:$port"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads and writes one app's enrollment.
|
|
||||||
*
|
|
||||||
* Two things are per-app and both are passed in rather than derived, because getting either wrong
|
|
||||||
* is silent rather than loud:
|
|
||||||
* - [scheme] is what routes a scanned QR back to the right app (`aiapp`, `devupdater`). Two apps
|
|
||||||
* accepting the same scheme would each offer to handle the other's enrollment.
|
|
||||||
* - [keyAlias] names the Android Keystore key the token is sealed under. It is **persisted**, so an
|
|
||||||
* app that changes it stops being able to unseal what it already stored, and the phone silently
|
|
||||||
* reads as not enrolled. Existing values must be carried over exactly when adopting this class.
|
|
||||||
*/
|
|
||||||
class ServerStore(private val scheme: String, private val keyAlias: String) {
|
|
||||||
|
|
||||||
fun load(context: Context): ServerSettings? {
|
|
||||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
val host = prefs.getString(KEY_HOST, null) ?: return null
|
|
||||||
val port = prefs.getInt(KEY_PORT, 0)
|
|
||||||
val sealed = prefs.getString(KEY_TOKEN, null) ?: return null
|
|
||||||
val token = unseal(sealed) ?: return null
|
|
||||||
if (port == 0 || token.isEmpty()) return null
|
|
||||||
return ServerSettings(host, port, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun save(context: Context, settings: ServerSettings) {
|
|
||||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
|
|
||||||
putString(KEY_HOST, settings.host)
|
|
||||||
putInt(KEY_PORT, settings.port)
|
|
||||||
putString(KEY_TOKEN, seal(settings.token))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the enrollment URI the QR carries, e.g.
|
|
||||||
* `aiapp://enroll?host=10.66.0.1&port=8443&token=...`.
|
|
||||||
*
|
|
||||||
* Null if any part is missing, so a malformed or foreign scan cannot clobber a working
|
|
||||||
* enrollment with a half-filled one.
|
|
||||||
*/
|
|
||||||
fun parseEnrollmentUri(uri: Uri): ServerSettings? {
|
|
||||||
if (uri.scheme != scheme || uri.host != "enroll") return null
|
|
||||||
val host = uri.getQueryParameter("host") ?: return null
|
|
||||||
val port = uri.getQueryParameter("port")?.toIntOrNull() ?: return null
|
|
||||||
val token = uri.getQueryParameter("token") ?: return null
|
|
||||||
if (host.isEmpty() || token.isEmpty()) return null
|
|
||||||
return ServerSettings(host, port, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Token sealing. The token authenticates requests to a server whose API is
|
|
||||||
// remote code execution, so it is stored AES-GCM-encrypted under an Android
|
|
||||||
// Keystore key (hardware-backed where the device has one) rather than in
|
|
||||||
// plain preferences. Hand-rolled rather than Jetpack's
|
|
||||||
// EncryptedSharedPreferences, which is deprecated with no drop-in
|
|
||||||
// successor -- Google's own guidance is now to use Keystore directly.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The key if there is one, without making one.
|
|
||||||
*
|
|
||||||
* The read side must never create. A sealed token with no key behind it means the key was lost
|
|
||||||
* -- a device reset, or the app's data restored onto another device, where Keystore keys do not
|
|
||||||
* travel -- and generating a fresh one there would leave a key nothing had ever sealed with and
|
|
||||||
* still fail to decrypt. Absent is the honest answer, and the caller reads it as "not
|
|
||||||
* enrolled".
|
|
||||||
*/
|
|
||||||
private fun existingTokenKey(): SecretKey? {
|
|
||||||
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
|
||||||
return keyStore.getKey(keyAlias, null) as? SecretKey
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tokenKey(): SecretKey {
|
|
||||||
existingTokenKey()?.let {
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
|
||||||
generator.init(
|
|
||||||
KeyGenParameterSpec.Builder(
|
|
||||||
keyAlias,
|
|
||||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
|
||||||
)
|
|
||||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
||||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
return generator.generateKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `iv:ciphertext`, both base64 -- the stored form of the token. */
|
|
||||||
private fun seal(token: String): String {
|
|
||||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, tokenKey())
|
|
||||||
val ciphertext = cipher.doFinal(token.encodeToByteArray())
|
|
||||||
return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) +
|
|
||||||
":" +
|
|
||||||
Base64.encodeToString(ciphertext, Base64.NO_WRAP)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Null on any failure, including the lost-key case above. The caller treats that as "not
|
|
||||||
* enrolled"; re-scanning the QR, or rotating the token server-side, is the recovery -- so
|
|
||||||
* failing soft here is right rather than lenient.
|
|
||||||
*/
|
|
||||||
private fun unseal(sealed: String): String? =
|
|
||||||
try {
|
|
||||||
val (ivB64, dataB64) =
|
|
||||||
sealed.split(":", limit = 2).let {
|
|
||||||
if (it.size != 2) return null else it[0] to it[1]
|
|
||||||
}
|
|
||||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
||||||
cipher.init(
|
|
||||||
Cipher.DECRYPT_MODE,
|
|
||||||
existingTokenKey() ?: return null,
|
|
||||||
GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)),
|
|
||||||
)
|
|
||||||
cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this app may reach a local network address at all.
|
|
||||||
*
|
|
||||||
* Android 17 made `ACCESS_LOCAL_NETWORK` mandatory for it, and a denial is invisible at the socket:
|
|
||||||
* the OS drops the traffic, so a blocked app and an unreachable server produce the same connect
|
|
||||||
* timeout. Without asking explicitly there is no way to tell those apart, and the failure shown
|
|
||||||
* would blame the server or the tunnel for something neither is doing.
|
|
||||||
*
|
|
||||||
* **The permission is still required when the server is reached through WireGuard, and the
|
|
||||||
* platform's own documentation says otherwise.** Android's Local Network Definition describes a
|
|
||||||
* local network as one that "utilizes a broadcast-capable network interface, such as Wi-Fi or
|
|
||||||
* Ethernet, but excludes cellular (WWAN) or VPN connections" — read straight, a tunnelled 10.66.0.1
|
|
||||||
* is excluded and needs nothing. Measured on a real device on 2026-08-28: it is not excluded, and
|
|
||||||
* without the permission the traffic is dropped. Do not remove the permission on the strength of
|
|
||||||
* that paragraph.
|
|
||||||
*
|
|
||||||
* **Neither project can catch this in an emulator.** The API 36 images both are tested against do
|
|
||||||
* not enforce the permission at all, so removing it passes every local test and fails only on a
|
|
||||||
* phone. That asymmetry is the reason this note is here rather than in a commit message.
|
|
||||||
*/
|
|
||||||
fun localNetworkAllowed(context: Context): Boolean =
|
|
||||||
android.os.Build.VERSION.SDK_INT < 37 ||
|
|
||||||
context.checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK") ==
|
|
||||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
// Both apps store under the same preferences name, which does not collide:
|
|
||||||
// SharedPreferences are per-application, so "server" means this app's server.
|
|
||||||
private const val PREFS_NAME = "server"
|
|
||||||
private const val KEY_HOST = "host"
|
|
||||||
private const val KEY_PORT = "port"
|
|
||||||
private const val KEY_TOKEN = "token"
|
|
||||||
|
|
||||||
private const val KEYSTORE = "AndroidKeyStore"
|
|
||||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
|
||||||
private const val GCM_TAG_BITS = 128
|
|
||||||
+4
-1
@@ -155,7 +155,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn addresses() -> Vec<IpAddr> {
|
fn addresses() -> Vec<IpAddr> {
|
||||||
vec!["192.168.1.5".parse().unwrap(), "127.0.0.1".parse().unwrap()]
|
vec![
|
||||||
|
"192.168.1.168".parse().unwrap(),
|
||||||
|
"127.0.0.1".parse().unwrap(),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+9
-240
@@ -24,10 +24,7 @@
|
|||||||
//! middleware, which stays in each project because it is generic over
|
//! middleware, which stays in each project because it is generic over
|
||||||
//! that project's state.
|
//! that project's state.
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::path::Path;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
@@ -71,62 +68,8 @@ pub fn token_matches(presented: &str, stored_hashes: &[String]) -> bool {
|
|||||||
/// The scheme is the caller's because it is what routes the scan back to
|
/// The scheme is the caller's because it is what routes the scan back to
|
||||||
/// the right app -- `devupdater`, `aiapp` -- and it is the only part of
|
/// the right app -- `devupdater`, `aiapp` -- and it is the only part of
|
||||||
/// enrollment that is per-project.
|
/// enrollment that is per-project.
|
||||||
///
|
pub fn enrollment_uri(scheme: &str, host: IpAddr, port: u16, token: &str) -> String {
|
||||||
/// `ca_pem` is the trust anchor, and is optional because the two projects
|
format!("{scheme}://enroll?host={host}&port={port}&token={token}")
|
||||||
/// answer "where does the app get the CA?" differently. An app built on
|
|
||||||
/// the machine its server runs on pins the CA at build time and needs
|
|
||||||
/// nothing here (pass `None`); one built somewhere else -- ai-app's iris
|
|
||||||
/// client is cross-compiled in a VM and run against the host's server --
|
|
||||||
/// cannot, so the CA travels with the link instead. See [`ca_param`] for
|
|
||||||
/// the encoding and what it costs a QR code.
|
|
||||||
pub fn enrollment_uri(
|
|
||||||
scheme: &str,
|
|
||||||
host: IpAddr,
|
|
||||||
port: u16,
|
|
||||||
token: &str,
|
|
||||||
ca_pem: Option<&str>,
|
|
||||||
) -> Result<String> {
|
|
||||||
let mut uri = format!("{scheme}://enroll?host={host}&port={port}&token={token}");
|
|
||||||
if let Some(ca_pem) = ca_pem {
|
|
||||||
uri.push_str("&ca=");
|
|
||||||
uri.push_str(&ca_param(ca_pem)?);
|
|
||||||
}
|
|
||||||
Ok(uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `ca` parameter's value for one PEM certificate: its DER, base64url
|
|
||||||
/// without padding, so it needs no percent-encoding and survives every
|
|
||||||
/// splitter a link passes through.
|
|
||||||
///
|
|
||||||
/// **The parameter is optional and unrecognised keys are ignored**, so a
|
|
||||||
/// link carrying it still enrolls an app that predates it -- which is what
|
|
||||||
/// makes adding it a compatible change to a format three languages parse
|
|
||||||
/// (this crate mints it; `client_core::config` and `ServerStore.kt` read
|
|
||||||
/// it).
|
|
||||||
///
|
|
||||||
/// Costs a QR code real estate: measured on ai-app's own P-256 CA, a link
|
|
||||||
/// goes from 89 bytes to 652 and its terminal QR from 45 to 93 columns.
|
|
||||||
/// That is why the parameter is the minter's choice per call rather than
|
|
||||||
/// always present.
|
|
||||||
pub fn ca_param(ca_pem: &str) -> Result<String> {
|
|
||||||
let der = pem_der(ca_pem)?;
|
|
||||||
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The DER inside a PEM certificate: everything between the BEGIN and END
|
|
||||||
/// lines, whitespace removed, base64-decoded.
|
|
||||||
fn pem_der(pem: &str) -> Result<Vec<u8>> {
|
|
||||||
const BEGIN: &str = "-----BEGIN CERTIFICATE-----";
|
|
||||||
const END: &str = "-----END CERTIFICATE-----";
|
|
||||||
let body = pem
|
|
||||||
.split_once(BEGIN)
|
|
||||||
.and_then(|(_, rest)| rest.split_once(END))
|
|
||||||
.map(|(body, _)| body)
|
|
||||||
.context("not a PEM certificate (no BEGIN/END CERTIFICATE lines)")?;
|
|
||||||
let body: String = body.chars().filter(|c| !c.is_whitespace()).collect();
|
|
||||||
base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body.as_bytes())
|
|
||||||
.context("a PEM certificate's body is not base64")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prints the one-time enrollment QR, and the URI under it for a person
|
/// Prints the one-time enrollment QR, and the URI under it for a person
|
||||||
@@ -136,17 +79,11 @@ fn pem_der(pem: &str) -> Result<Vec<u8>> {
|
|||||||
/// at the terminal, once, and a log line is the wrong shape for something
|
/// at the terminal, once, and a log line is the wrong shape for something
|
||||||
/// that has to be photographed.
|
/// that has to be photographed.
|
||||||
///
|
///
|
||||||
/// The QR carries no *secret* beyond the token, which is rotatable:
|
/// The QR carries no trust material. The CA is embedded in the app at
|
||||||
/// `ca_pem`, where the caller passes one, is a public certificate, so
|
/// build time, so photographing the terminal leaks only the token, which
|
||||||
/// photographing the terminal still leaks only the token.
|
/// is rotatable.
|
||||||
pub fn print_enrollment(
|
pub fn print_enrollment(scheme: &str, host: IpAddr, port: u16, token: &str) -> Result<()> {
|
||||||
scheme: &str,
|
let uri = enrollment_uri(scheme, host, port, token);
|
||||||
host: IpAddr,
|
|
||||||
port: u16,
|
|
||||||
token: &str,
|
|
||||||
ca_pem: Option<&str>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let uri = enrollment_uri(scheme, host, port, token, ca_pem)?;
|
|
||||||
let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?;
|
let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?;
|
||||||
let rendered = code
|
let rendered = code
|
||||||
.render::<qrcode::render::unicode::Dense1x2>()
|
.render::<qrcode::render::unicode::Dense1x2>()
|
||||||
@@ -160,74 +97,6 @@ pub fn print_enrollment(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long a spooled enrolment stays valid unused. The link is meant to be
|
|
||||||
/// opened straight away, by the tool that asked for it; one that was never
|
|
||||||
/// opened should not stay a valid credential on disk.
|
|
||||||
pub const PENDING_TTL: Duration = Duration::from_secs(60 * 60);
|
|
||||||
|
|
||||||
/// Records a token minted by another process for the running server to
|
|
||||||
/// adopt on first use -- see [`take_pending`].
|
|
||||||
///
|
|
||||||
/// Why a spool rather than writing the config: the server holds its config
|
|
||||||
/// in memory and writes it back whole, so a second process appending a
|
|
||||||
/// token to the file loses the race with the next save, silently. Here the
|
|
||||||
/// other process writes only into `dir` (created private to the user), one
|
|
||||||
/// file per token, named by the hash and holding the device name; the
|
|
||||||
/// server owns the config as before and moves the entry across itself.
|
|
||||||
/// Only the hash touches disk, as with every stored token.
|
|
||||||
pub fn spool_pending(dir: &Path, name: &str, token: &str) -> Result<()> {
|
|
||||||
fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
|
|
||||||
.with_context(|| format!("restrict {}", dir.display()))?;
|
|
||||||
}
|
|
||||||
let path = dir.join(token_hash_hex(token));
|
|
||||||
fs::write(&path, name).with_context(|| format!("write {}", path.display()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adopts a spooled token if `presented` is one: returns the device name
|
|
||||||
/// it was spooled under and removes the entry, so a spooled token is
|
|
||||||
/// consumed exactly once and belongs to the config from then on. Anything
|
|
||||||
/// older than [`PENDING_TTL`] is removed rather than honoured.
|
|
||||||
///
|
|
||||||
/// A missing directory is the common case -- nothing has ever been
|
|
||||||
/// spooled -- and answers `None` like an empty one.
|
|
||||||
pub fn take_pending(dir: &Path, presented: &str) -> Result<Option<String>> {
|
|
||||||
let entries = match fs::read_dir(dir) {
|
|
||||||
Ok(entries) => entries,
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
||||||
Err(error) => return Err(error).with_context(|| format!("read {}", dir.display())),
|
|
||||||
};
|
|
||||||
let wanted = token_hash_hex(presented);
|
|
||||||
let mut found = None;
|
|
||||||
for entry in entries {
|
|
||||||
let entry = entry.with_context(|| format!("read {}", dir.display()))?;
|
|
||||||
let path = entry.path();
|
|
||||||
let fresh = entry
|
|
||||||
.metadata()
|
|
||||||
.and_then(|meta| meta.modified())
|
|
||||||
.ok()
|
|
||||||
.and_then(|modified| modified.elapsed().ok())
|
|
||||||
.is_some_and(|age| age < PENDING_TTL);
|
|
||||||
if !fresh {
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = entry.file_name();
|
|
||||||
let name = name.to_string_lossy();
|
|
||||||
if bool::from(name.as_bytes().ct_eq(wanted.as_bytes())) {
|
|
||||||
found = Some(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let Some(path) = found else { return Ok(None) };
|
|
||||||
let device = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
|
|
||||||
fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
|
|
||||||
Ok(Some(device))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -266,112 +135,12 @@ mod tests {
|
|||||||
/// back -- so the shape is a contract, not a formatting choice.
|
/// back -- so the shape is a contract, not a formatting choice.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_enrollment_uri_carries_scheme_host_port_and_token() {
|
fn the_enrollment_uri_carries_scheme_host_port_and_token() {
|
||||||
let uri = enrollment_uri(
|
let uri = enrollment_uri("devupdater", "10.66.0.1".parse().unwrap(), 8090, "tok");
|
||||||
"devupdater",
|
|
||||||
"10.66.0.1".parse().unwrap(),
|
|
||||||
8090,
|
|
||||||
"tok",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
uri,
|
uri,
|
||||||
"devupdater://enroll?host=10.66.0.1&port=8090&token=tok"
|
"devupdater://enroll?host=10.66.0.1&port=8090&token=tok"
|
||||||
);
|
);
|
||||||
let other =
|
let other = enrollment_uri("aiapp", "10.66.0.1".parse().unwrap(), 8443, "tok");
|
||||||
enrollment_uri("aiapp", "10.66.0.1".parse().unwrap(), 8443, "tok", None).unwrap();
|
|
||||||
assert!(other.starts_with("aiapp://enroll?"));
|
assert!(other.starts_with("aiapp://enroll?"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The CA rides as base64url of the DER, appended to the same link --
|
|
||||||
/// so a reader that ignores unknown keys sees exactly the link above.
|
|
||||||
#[test]
|
|
||||||
fn a_ca_rides_in_the_link_as_url_safe_base64_der() {
|
|
||||||
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
|
|
||||||
let pem = format!(
|
|
||||||
"-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n",
|
|
||||||
base64::engine::general_purpose::STANDARD.encode(der)
|
|
||||||
);
|
|
||||||
let uri = enrollment_uri(
|
|
||||||
"aiapp",
|
|
||||||
"10.66.0.1".parse().unwrap(),
|
|
||||||
8443,
|
|
||||||
"tok",
|
|
||||||
Some(&pem),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let (base, ca) = uri.split_once("&ca=").expect("the ca parameter");
|
|
||||||
assert_eq!(base, "aiapp://enroll?host=10.66.0.1&port=8443&token=tok");
|
|
||||||
assert_eq!(
|
|
||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
||||||
.decode(ca)
|
|
||||||
.unwrap(),
|
|
||||||
der,
|
|
||||||
"the parameter is the certificate's DER, url-safe and unpadded"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!ca.contains(['+', '/', '=']),
|
|
||||||
"nothing in it needs percent-encoding: {ca}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Something that is not a certificate is refused where it is read,
|
|
||||||
/// rather than minting a link an app can only fail on.
|
|
||||||
#[test]
|
|
||||||
fn a_ca_that_is_not_a_certificate_is_named_in_the_error() {
|
|
||||||
let err = ca_param("hello").unwrap_err().to_string();
|
|
||||||
assert!(err.contains("PEM certificate"), "{err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scratch_dir() -> std::path::PathBuf {
|
|
||||||
let dir = std::env::temp_dir().join(format!("wg-app-link-enroll-{}", generate_token()));
|
|
||||||
fs::create_dir_all(&dir).unwrap();
|
|
||||||
dir
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A spooled token is adopted once, under the name it was spooled with,
|
|
||||||
/// and by nothing but that token.
|
|
||||||
#[test]
|
|
||||||
fn a_spooled_token_is_taken_exactly_once() {
|
|
||||||
let dir = scratch_dir();
|
|
||||||
let token = generate_token();
|
|
||||||
spool_pending(&dir, "tablet", &token).unwrap();
|
|
||||||
assert_eq!(take_pending(&dir, "wrong").unwrap(), None);
|
|
||||||
assert_eq!(
|
|
||||||
take_pending(&dir, &token).unwrap().as_deref(),
|
|
||||||
Some("tablet")
|
|
||||||
);
|
|
||||||
assert_eq!(take_pending(&dir, &token).unwrap(), None, "consumed");
|
|
||||||
assert!(
|
|
||||||
fs::read_dir(&dir).unwrap().next().is_none(),
|
|
||||||
"nothing left behind"
|
|
||||||
);
|
|
||||||
fs::remove_dir_all(dir).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nothing spooled -- not even the directory -- is an ordinary miss.
|
|
||||||
#[test]
|
|
||||||
fn no_spool_is_a_miss() {
|
|
||||||
let dir = scratch_dir().join("never-made");
|
|
||||||
assert_eq!(take_pending(&dir, "anything").unwrap(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An entry past its age is swept rather than honoured.
|
|
||||||
#[test]
|
|
||||||
fn a_stale_entry_is_swept_not_honoured() {
|
|
||||||
let dir = scratch_dir();
|
|
||||||
let token = generate_token();
|
|
||||||
spool_pending(&dir, "old", &token).unwrap();
|
|
||||||
let path = dir.join(token_hash_hex(&token));
|
|
||||||
let past = std::time::SystemTime::now() - PENDING_TTL - Duration::from_secs(1);
|
|
||||||
fs::File::options()
|
|
||||||
.write(true)
|
|
||||||
.open(&path)
|
|
||||||
.unwrap()
|
|
||||||
.set_modified(past)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(take_pending(&dir, &token).unwrap(), None);
|
|
||||||
assert!(!path.exists(), "swept");
|
|
||||||
fs::remove_dir_all(dir).unwrap();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -35,13 +35,8 @@
|
|||||||
/// `#![enable(implicit_some)]` header every project file would have to
|
/// `#![enable(implicit_some)]` header every project file would have to
|
||||||
/// remember, and matched on the writing side by `skip_serializing_if` so
|
/// remember, and matched on the writing side by `skip_serializing_if` so
|
||||||
/// nothing writes back a `Some(...)` a person didn't type.
|
/// nothing writes back a `Some(...)` a person didn't type.
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use serde::{Serialize, de::DeserializeOwned};
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
|
|
||||||
use crate::private;
|
|
||||||
|
|
||||||
fn options() -> ron::Options {
|
fn options() -> ron::Options {
|
||||||
ron::Options::default().with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME)
|
ron::Options::default().with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME)
|
||||||
}
|
}
|
||||||
@@ -56,40 +51,6 @@ pub fn render<T: Serialize>(value: &T) -> Result<String, ron::Error> {
|
|||||||
Ok(unwrap_outer(&text))
|
Ok(unwrap_outer(&text))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders `value` and replaces `path` with it, atomically and owner-only.
|
|
||||||
///
|
|
||||||
/// Whole-file-and-rename rather than an in-place edit, because both
|
|
||||||
/// projects' config files are small, are read at startup, and hold the
|
|
||||||
/// enrolled token hashes -- a half-written one would take the server down
|
|
||||||
/// on its next start with no way to fix it from a phone. The rename is
|
|
||||||
/// what makes a reader see either the old file or the new one and never
|
|
||||||
/// part of both.
|
|
||||||
///
|
|
||||||
/// The temp file goes through [`private::write_file`] rather than
|
|
||||||
/// `std::fs::write`, and that is the subtle half: **the temp file is not
|
|
||||||
/// always new.** A save killed partway leaves one behind, and opening that
|
|
||||||
/// again keeps whatever mode it already had -- which is then renamed over
|
|
||||||
/// the file holding the token hashes. Setting the mode as it is opened
|
|
||||||
/// covers both the fresh and the leftover case.
|
|
||||||
pub fn write<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
private::create_dir(parent)?;
|
|
||||||
}
|
|
||||||
let text = render(value).context("serialize config")?;
|
|
||||||
// Appended rather than substituted, so `config.ron` yields
|
|
||||||
// `config.ron.tmp` and not `config.tmp` -- a name that cannot collide
|
|
||||||
// with a real file and that says what it is a temporary copy of.
|
|
||||||
let tmp = path.with_file_name(format!(
|
|
||||||
"{}.tmp",
|
|
||||||
path.file_name()
|
|
||||||
.unwrap_or_else(|| std::ffi::OsStr::new("config"))
|
|
||||||
.to_string_lossy()
|
|
||||||
));
|
|
||||||
private::write_file(&tmp, text.as_bytes())?;
|
|
||||||
std::fs::rename(&tmp, path)
|
|
||||||
.with_context(|| format!("replace {} with {}", path.display(), tmp.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strips the outer `(`/`)` the writer always emits and removes the
|
/// Strips the outer `(`/`)` the writer always emits and removes the
|
||||||
/// indent level they cost. Deliberately narrow: it accepts only the
|
/// indent level they cost. Deliberately narrow: it accepts only the
|
||||||
/// exact shape `PrettyConfig` produces, and leaves anything else alone
|
/// exact shape `PrettyConfig` produces, and leaves anything else alone
|
||||||
@@ -185,57 +146,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn what_is_written_reads_back_and_is_owner_only() {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("nested").join("config.ron");
|
|
||||||
let value = Demo {
|
|
||||||
name: "thing".to_string(),
|
|
||||||
note: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
write(&path, &value).expect("write");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
parse::<Demo>(&std::fs::read_to_string(&path).expect("read")).expect("re-read"),
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
let mode = std::fs::metadata(&path).expect("stat").permissions().mode();
|
|
||||||
assert_eq!(mode & 0o777, 0o600, "config holds token hashes: {mode:o}");
|
|
||||||
assert!(
|
|
||||||
!path.with_extension("ron.tmp").exists(),
|
|
||||||
"the temp file is renamed away, not left behind",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The case the whole thing turns on, and the one that cannot happen on
|
|
||||||
/// a machine where nothing has ever crashed mid-save: a leftover temp
|
|
||||||
/// file from an interrupted write is reopened, and if its mode came
|
|
||||||
/// along it would be renamed straight over the file holding the enrolled
|
|
||||||
/// token hashes.
|
|
||||||
#[test]
|
|
||||||
fn a_leftover_temp_file_cannot_widen_the_config() {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("config.ron");
|
|
||||||
let tmp = dir.path().join("config.ron.tmp");
|
|
||||||
|
|
||||||
std::fs::write(&tmp, b"leftover from a save that died").expect("stale temp");
|
|
||||||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).expect("widen");
|
|
||||||
|
|
||||||
write(&path, &Demo::default()).expect("write");
|
|
||||||
|
|
||||||
let mode = std::fs::metadata(&path).expect("stat").permissions().mode();
|
|
||||||
assert_eq!(
|
|
||||||
mode & 0o777,
|
|
||||||
0o600,
|
|
||||||
"a world-readable leftover must not become the config: {mode:o}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A parse error's line number has to point at the real line, which is
|
/// A parse error's line number has to point at the real line, which is
|
||||||
/// why the opening paren is not followed by a newline.
|
/// why the opening paren is not followed by a newline.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+1
-1
@@ -114,7 +114,7 @@ mod tests {
|
|||||||
/// and nothing may appear twice, since these become certificate SANs.
|
/// and nothing may appear twice, since these become certificate SANs.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_certificate_always_covers_loopback_and_the_emulator_alias() {
|
fn the_certificate_always_covers_loopback_and_the_emulator_alias() {
|
||||||
let found = [ip("192.168.1.5"), ip("10.66.0.1"), ip("192.168.1.5")];
|
let found = [ip("192.168.1.168"), ip("10.66.0.1"), ip("192.168.1.168")];
|
||||||
let addresses = addresses_among(found);
|
let addresses = addresses_among(found);
|
||||||
|
|
||||||
assert!(addresses.contains(&ip("127.0.0.1")));
|
assert!(addresses.contains(&ip("127.0.0.1")));
|
||||||
|
|||||||
Reference in new issue
Block a user