feat(tools): Android canonical-vector instrument harness + hardened LEARNINGS verification helper; update capture script KNOWN_GAPS
This commit is contained in:
parent
6cc78209af
commit
c3092ad8f6
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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 "$@"
|
||||||
Loading…
Reference in New Issue