Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions agent/android/app/src/main/java/app/muster/agent/EnrollActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ class EnrollActivity : Activity() {
deviceName = android.os.Build.MODEL ?: "android",
)

if (flow.isEnrolled()) {
// muster#105: an enrolled device is told what it is, not offered
// a form that can only re-present a spent code. The form is
// disabled rather than hidden so the screen still reads as the
// enrollment screen, and the back button still leaves - nothing
// here traps the operator the way the retry loop did.
say("This device is already enrolled.")
findViewById<EditText>(R.id.code).isEnabled = false
findViewById<Button>(R.id.enroll).isEnabled = false
return
}

findViewById<Button>(R.id.enroll).setOnClickListener {
val code = findViewById<EditText>(R.id.code).text.toString().trim()
if (code.isEmpty()) return@setOnClickListener
Expand Down
26 changes: 24 additions & 2 deletions agent/android/app/src/main/java/app/muster/agent/EnrollmentFlow.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ class EnrollmentFlow(

private var failures = 0

/**
* Whether this device already holds an identity, by any route.
*
* The enrollment screen asks this before offering the form, and both
* loops ask it before each attempt: a certificate that arrives while the
* screen is open ends the loop, instead of the loop re-presenting a spent
* code until somebody force-quits (muster#105).
*/
fun isEnrolled(): Boolean = store.hasIdentity()

/**
* Present this device against a pairing code.
*
Expand All @@ -71,6 +81,11 @@ class EnrollmentFlow(
* to act, and telling them so is more useful than a spinner.
*/
fun present(code: String): Step {
// Presenting when the device already holds an identity is how a spent
// code gets back on the wire: the first presentation used it, and this
// one can only be told so. Refused before the keystore is even
// touched, let alone the network.
if (store.hasIdentity()) return Step.Enrolled
val material = keys.ensure()
val csr = CertificateRequest.toPem(
CertificateRequest.build(deviceName, material.publicKey, material.signer)
Expand Down Expand Up @@ -115,8 +130,14 @@ class EnrollmentFlow(
* backoff. Counting it would push the poll interval out to an hour while
* the operator is standing there wondering why nothing happens.
*/
fun collect(requestId: String): Step =
when (val outcome = client.collect(requestId)) {
fun collect(requestId: String): Step {
// A certificate that arrived by any route ends the loop: the poll is
// waiting on the server, but the device already has what it was
// waiting for. Checked before the network so a stray poll landing
// after enrollment cannot overwrite "Enrolled" with "this enrollment
// is no longer available".
if (store.hasIdentity()) return Step.Enrolled
return when (val outcome = client.collect(requestId)) {
is EnrollmentClient.Collected.Issued -> {
store.save(
outcome.certificatePem,
Expand All @@ -143,6 +164,7 @@ class EnrollmentFlow(
)
}
}
}

companion object {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,40 @@ class EnrollmentFlowTest {
assertTrue("backoff should have grown before the reset", grown > afterReset)
assertEquals(IdentityLifecycle.backoffSeconds(1), afterReset)
}

// ---- muster#105: the already-enrolled device ---------------------------

@Test
fun anAlreadyEnrolledDeviceIsToldSoInsteadOfPresenting() {
// The enrollment screen must refuse to present when the device already
// holds an identity, by any route. The transport script is empty, so
// any network touch would throw (surfaced as Unreachable) - returning
// Enrolled without a single request is the whole assertion, and it is
// also what keeps a spent code off the wire.
val store = RecordingStore().also {
it.saved = listOf("CERT", "CA", "not-after", "renew-after")
}
val (subject, _, _) = flow(mutableListOf(), store = store)

assertSame(EnrollmentFlow.Step.Enrolled, subject.present("714908"))
}

@Test
fun aCertificateArrivingByAnotherRouteEndsThePoll() {
// The retry loop re-checks identity before each attempt, so a
// certificate that arrives while the screen is open ends the loop
// instead of the loop presenting a spent code forever.
val store = RecordingStore()
val (subject, _, _) = flow(
mutableListOf(accepted(), EnrollmentClient.Transport.Reply(202, "{}")),
store = store,
)

val presented = subject.present("714908") as EnrollmentFlow.Step.AwaitingVouch
// The certificate arrives by another route: provisioning finishes, a
// second screen enrolls, the setup wizard hands over.
store.saved = listOf("CERT", "CA", "not-after", "renew-after")

assertSame(EnrollmentFlow.Step.Enrolled, subject.collect(presented.requestId))
}
}
Loading