From c3092ad8f644ca78bb73ae403908c64022c78807 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 28 Jun 2026 17:50:00 -0500 Subject: [PATCH] feat(tools): Android canonical-vector instrument harness + hardened LEARNINGS verification helper; update capture script KNOWN_GAPS --- .../CanonicalVectorCaptureInstrumentTest.kt | 180 ++++++++++++++++++ scripts/capture_android_canonical_vectors.sh | 38 ++-- scripts/verify-learnings-update.sh | 118 ++++++++++++ 3 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 app/src/androidTest/java/app/closer/crypto/CanonicalVectorCaptureInstrumentTest.kt create mode 100755 scripts/verify-learnings-update.sh diff --git a/app/src/androidTest/java/app/closer/crypto/CanonicalVectorCaptureInstrumentTest.kt b/app/src/androidTest/java/app/closer/crypto/CanonicalVectorCaptureInstrumentTest.kt new file mode 100644 index 00000000..488bc13b --- /dev/null +++ b/app/src/androidTest/java/app/closer/crypto/CanonicalVectorCaptureInstrumentTest.kt @@ -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) } + } +} diff --git a/scripts/capture_android_canonical_vectors.sh b/scripts/capture_android_canonical_vectors.sh index 687cd2f9..2ae9170e 100755 --- a/scripts/capture_android_canonical_vectors.sh +++ b/scripts/capture_android_canonical_vectors.sh @@ -140,25 +140,35 @@ fi 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 "Skipping Android-side capture. Add a small instrument/debug helper to fill these values." - cat <<'GAP' >&2 + cat <<'KNOWN_GAPS' >&2 == 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: -1. Add an androidTest instrument class (e.g. - app/src/androidTest/java/app/closer/crypto/CanonicalVectorCaptureTest.kt) - that computes the same 3 sealed-answer canonical JSON + commitment vectors - 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`. +It is a DEBUG-ONLY instrument test (never production code). It prints: + - 3 sealed-answer canonical JSON + commitment vectors + - 1 Argon2id known vector + - 1 sealed-answer round-trip sanity check -Until one of the above lands, this script will capture iOS output only and -report ANDROID_TEST_HARNESS_MISSING in the summary. +to logcat with the tag `CanonicalVectorCapture` in this format: + 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 # --------------------------------------------------------------------------- diff --git a/scripts/verify-learnings-update.sh b/scripts/verify-learnings-update.sh new file mode 100755 index 00000000..5a2c3413 --- /dev/null +++ b/scripts/verify-learnings-update.sh @@ -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 +# scripts/verify-learnings-update.sh check +# +# Conventions: +# LEARNINGS path: .learnings//LEARNINGS.md +# Baseline store: .learnings//.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 < + $0 check + + snapshot Capture the current mtime of .learnings//LEARNINGS.md + and write it to .learnings//.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 "$@"