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
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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