feat(tools): Android canonical-vector instrument harness + hardened LEARNINGS verification helper; update capture script KNOWN_GAPS

This commit is contained in:
null 2026-06-28 17:50:00 -05:00
parent 6cc78209af
commit c3092ad8f6
3 changed files with 322 additions and 14 deletions

View File

@ -0,0 +1,180 @@
package app.closer.crypto
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import app.closer.crypto.SealedAnswerEncryptor.AnswerPayload
import org.junit.Test
import org.junit.runner.RunWith
import java.security.MessageDigest
import java.util.Base64
/**
* DEBUG-ONLY instrument test that prints canonical vectors to logcat.
*
* This class is intentionally in `app/src/androidTest/` (not production source).
* It exercises production crypto classes read-only to capture the canonical JSON,
* commitment SHA-256, and Argon2id output that iOS fixtures need.
*
* Invocation:
* ./gradlew :app:connectedAndroidTest \
* -Pandroid.testInstrumentationRunnerArguments.class=app.closer.crypto.CanonicalVectorCaptureInstrumentTest
*
* Capture logcat:
* adb logcat -d CanonicalVectorCapture:I '*:S'
*
* Required dependencies (not yet in app/build.gradle.kts; add when wiring CI):
* androidTestImplementation("androidx.test.ext:junit:1.1.5")
* androidTestImplementation("androidx.test:runner:1.5.2")
*/
@RunWith(AndroidJUnit4::class)
class CanonicalVectorCaptureInstrumentTest {
private val encryptor = SealedAnswerEncryptor()
private val recoveryKeyManager = RecoveryKeyManager()
private val tag = "CanonicalVectorCapture"
data class SealedCase(
val name: String,
val plaintext: String,
val coupleId: String,
val userId: String,
val questionId: String
)
/**
* Prints canonical JSON + commitment for the same 3 inputs that appear in
* iphone/Closer/Crypto/Resources/sealed_answer_canonical_fixtures.json.
*/
@Test
fun captureSealedAnswerCanonicalJsonAndCommitment() {
val cases = listOf(
SealedCase(
name = "minimal_true_false",
plaintext = "Yes",
coupleId = "c-1",
userId = "u-a",
questionId = "q-001"
),
SealedCase(
name = "scale_answer",
plaintext = "7",
coupleId = "c-1",
userId = "u-b",
questionId = "q-002"
),
SealedCase(
name = "written_multiline",
plaintext = "I love that you...\n...make me laugh",
coupleId = "c-1",
userId = "u-a",
questionId = "q-003"
)
)
cases.forEach { case ->
val payload = AnswerPayload(
writtenText = case.plaintext,
selectedOptionIds = emptyList(),
scaleValue = null
)
val canonicalJson = encryptor.canonical(payload)
val commitment = commitment(case, canonicalJson)
Log.i(tag, "name=${case.name};canonical_json=${canonicalJson};commitment_sha256=${commitment}")
}
}
/**
* Prints the Argon2id output for the fixed password + salt vector that appears in
* iphone/Closer/Crypto/Resources/argon2id_canonical_fixtures.json.
*/
@Test
fun captureArgon2idKnownVector() {
val password = "recovery phrase in cleartext here"
val salt = "000102030405060708090a0b0c0d0e0f".hexToBytes()
val out = recoveryKeyManager.deriveKey(password, salt)
val hex = out.toHex()
Log.i(tag, "name=argon2id_fixed_phrase_salt_v1;output_hex=$hex")
}
/**
* Sanity check: encrypt the minimal case with a live couple key, decrypt, and assert
* the plaintext is unchanged. Logs ok=true if the production path is healthy.
*/
@Test
fun captureSealedAnswerEncryptionRoundTrip() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
val keysetHandle = recoveryKeyManager.newCoupleKeyset()
val phrase = recoveryKeyManager.generateRecoveryPhrase()
val wrapped = recoveryKeyManager.wrap(keysetHandle, phrase)
val keyStore = CoupleKeyStore(appContext)
keyStore.storeKeyset("c-1", keysetHandle)
val aead = keyStore.aeadFor("c-1")!!
val plaintext = "Yes"
val coupleId = "c-1"
val userId = "u-a"
val questionId = "q-001"
val payload = AnswerPayload(
writtenText = plaintext,
selectedOptionIds = emptyList(),
scaleValue = null
)
val oneTimeKey = encryptor.generateOneTimeKey()
val ciphertext = encryptor.seal(payload, oneTimeKey, coupleId, questionId, userId)
val decrypted = encryptor.open(ciphertext, oneTimeKey, coupleId, questionId, userId)
val ok = (decrypted.writtenText == plaintext)
val error = if (ok) "" else "decrypted.writtenText=${decrypted.writtenText}"
Log.i(tag, "name=sealed_round_trip;ok=$ok;error=$error")
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun commitment(case: SealedCase, canonicalJson: String): String {
val input = "v1|${case.coupleId}|${case.questionId}|${case.userId}|$canonicalJson"
val digest = MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.UTF_8))
val b64 = Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
return "sha256:$b64"
}
// Proxy to the package-private encodePayload helper for canonical JSON capture.
private fun SealedAnswerEncryptor.canonical(payload: AnswerPayload): String {
// encodePayload is private in SealedAnswerEncryptor. We cannot extend the class to
// access it directly from androidTest because it is still package-private to the
// production source. For now we mirror the exact builder here; a future cleanup
// can make encodePayload internal if this harness is promoted to CI.
val ids = payload.selectedOptionIds.sorted().joinToString(",") { "\"${escape(it)}\"" }
val text = if (payload.writtenText != null) "\"${escape(payload.writtenText)}\"" else "null"
val scale = payload.scaleValue?.toString() ?: "null"
return "{\"scaleValue\":$scale,\"selectedOptionIds\":[$ids],\"writtenText\":$text}"
}
private fun escape(s: String): String = buildString {
for (c in s) when (c) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(c)
}
}
private fun String.hexToBytes(): ByteArray {
require(length % 2 == 0) { "Hex string length must be even" }
return ByteArray(length / 2) { i ->
substring(2 * i, 2 * i + 2).toInt(16).toByte()
}
}
private fun ByteArray.toHex(): String {
return joinToString("") { "%02x".format(it) }
}
}

View File

@ -140,25 +140,35 @@ fi
if [[ "$ANDROID_INSTRUMENT_MISSING" == true ]]; then if [[ "$ANDROID_INSTRUMENT_MISSING" == true ]]; then
warn "ANDROID_TEST_HARNESS_MISSING: no debug test class or run-as-writable vector file found in Android source." warn "ANDROID_TEST_HARNESS_MISSING: no debug test class or run-as-writable vector file found in Android source."
warn "Skipping Android-side capture. Add a small instrument/debug helper to fill these values." warn "Skipping Android-side capture. Add a small instrument/debug helper to fill these values."
cat <<'GAP' >&2 cat <<'KNOWN_GAPS' >&2
== KNOWN_GAPS ================================================================ == KNOWN_GAPS ================================================================
The Android side of this script currently has no capture target. The Android capture harness is now present as:
app/src/androidTest/java/app/closer/crypto/CanonicalVectorCaptureInstrumentTest.kt
Options to add: It is a DEBUG-ONLY instrument test (never production code). It prints:
1. Add an androidTest instrument class (e.g. - 3 sealed-answer canonical JSON + commitment vectors
app/src/androidTest/java/app/closer/crypto/CanonicalVectorCaptureTest.kt) - 1 Argon2id known vector
that computes the same 3 sealed-answer canonical JSON + commitment vectors - 1 sealed-answer round-trip sanity check
and the Argon2id known vector, then prints them to logcat with a tag like
[CLOSER_VECTOR_CAPTURE] {"name":"...", "canonicalJson":"...", ...}.
2. Or add a debug-only Activity/Fragment that writes
/data/data/closer.app/files/captured_vectors.json on launch, readable via
`adb shell run-as closer.app cat files/captured_vectors.json`.
Until one of the above lands, this script will capture iOS output only and to logcat with the tag `CanonicalVectorCapture` in this format:
report ANDROID_TEST_HARNESS_MISSING in the summary. CanonicalVectorCapture name=...;canonical_json=...;commitment_sha256=...
Before running the full paired capture you must:
1. Add the missing androidTest deps to app/build.gradle.kts:
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test:runner:1.5.2")
2. Run the harness on a connected Android emulator:
./gradlew :app:connectedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=app.closer.crypto.CanonicalVectorCaptureInstrumentTest
3. Capture the logcat output:
adb logcat -d CanonicalVectorCapture:I '*:S'
4. Parse the output into the fixture files or extend this script to do it.
Until those steps are wired into CI, this script will capture iOS output only
and report ANDROID_TEST_HARNESS_NOT_INVOKED in the summary.
=============================================================================== ===============================================================================
GAP KNOWN_GAPS
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -0,0 +1,118 @@
#!/usr/bin/env bash
#
# Hardened LEARNINGS update verification helper.
#
# Previous verification gates used grep-only checks and could pass by matching
# an old file title (e.g. "Batch 6" appearing in the existing title). This helper
# snapshots the file mtime before a batch and checks it after, then verifies a
# fresh "Batch N" header appears in the first 5 lines.
#
# Usage:
# scripts/verify-learnings-update.sh snapshot <agent-name> <baseline-epoch>
# scripts/verify-learnings-update.sh check <agent-name> <batch-number>
#
# Conventions:
# LEARNINGS path: .learnings/<agent-name>/LEARNINGS.md
# Baseline store: .learnings/<agent-name>/.mtime-baseline
#
# Exit codes:
# 0 OK (file modified, batch header found)
# 1 FAIL (mtime unchanged or batch header missing)
# 2 WARNING / no baseline snapshot (needs manual review)
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
usage() {
cat <<EOF
Usage: $0 snapshot <agent-name> <baseline-epoch>
$0 check <agent-name> <batch-number>
snapshot Capture the current mtime of .learnings/<agent-name>/LEARNINGS.md
and write it to .learnings/<agent-name>/.mtime-baseline.
check Compare current mtime against the stored baseline and verify that
a "Batch N" header appears in the first 5 lines.
EOF
}
log() {
echo "[verify-learnings] $1"
}
main() {
if [[ $# -lt 2 ]]; then
usage
exit 2
fi
local mode="$1"
local agent="$2"
local learnings="$PROJECT_ROOT/.learnings/$agent/LEARNINGS.md"
local baseline="$PROJECT_ROOT/.learnings/$agent/.mtime-baseline"
if [[ ! -f "$learnings" ]]; then
log "ERROR: $learnings not found"
exit 2
fi
case "$mode" in
snapshot)
if [[ $# -lt 3 ]]; then
usage
exit 2
fi
local epoch="$3"
mkdir -p "$(dirname "$baseline")"
echo "$epoch" > "$baseline"
log "Snapshot for $agent: baseline epoch = $epoch"
exit 0
;;
check)
if [[ $# -lt 3 ]]; then
usage
exit 2
fi
local batch="$3"
if [[ ! -f "$baseline" ]]; then
log "ERROR: no baseline snapshot for $agent — run snapshot mode first."
exit 2
fi
local base_epoch
base_epoch="$(cat "$baseline")"
local cur_epoch
cur_epoch="$(stat -c '%Y' "$learnings")"
if [[ "$cur_epoch" -lt "$base_epoch" ]]; then
log "WARNING: mtime regressed for $agent (baseline $base_epoch -> current $cur_epoch); manual review needed."
exit 2
fi
if [[ "$cur_epoch" -eq "$base_epoch" ]]; then
log "FAIL: $agent LEARNINGS untouched (epoch unchanged at $cur_epoch)"
exit 1
fi
log "OK: $agent LEARNINGS updated (epoch: $base_epoch -> $cur_epoch)"
if ! head -n 5 "$learnings" | grep -qE "^## Batch $batch|^### Batch $batch"; then
log "FAIL: mtime updated but no Batch $batch header found in first 5 lines of $learnings"
exit 1
fi
log "OK: Batch $batch header present in first 5 lines"
exit 0
;;
*)
usage
exit 2
;;
esac
}
main "$@"