From 324b3c5402ce87e0dc6ead47ce0b67759e94de63 Mon Sep 17 00:00:00 2001 From: Silent1566 <245409147+Silent1566@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:43:15 +0800 Subject: [PATCH] feat: define verified probe rule sidecar --- .../android/tv/ad/audio/ProbeRuleSidecar.java | 104 ++++++++++++++++ .../tv/ad/audio/ProbeRuleSidecarTest.java | 117 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecar.java create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecarTest.java diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecar.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecar.java new file mode 100644 index 0000000000..c840d2ffb4 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecar.java @@ -0,0 +1,104 @@ +package com.fongmi.android.tv.ad.audio; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; +import java.util.regex.Pattern; + +public final class ProbeRuleSidecar { + + public static final String ALGORITHM_ID = "spectral-sequence-v1"; + + private static final int DIGEST_BYTES = 32; + private static final Pattern CONVERTER_ID = Pattern.compile("[A-Za-z0-9._-]{1,64}"); + + private final long sourceRevision; + private final byte[] sourceDigest; + private final String algorithm; + private final String converterVersion; + private final byte[] canonicalRules; + private final byte[] sidecarDigest; + + private ProbeRuleSidecar(long sourceRevision, byte[] sourceDigest, String algorithm, + String converterVersion, byte[] canonicalRules, + byte[] sidecarDigest) { + this.sourceRevision = sourceRevision; + this.sourceDigest = sourceDigest; + this.algorithm = algorithm; + this.converterVersion = converterVersion; + this.canonicalRules = canonicalRules; + this.sidecarDigest = sidecarDigest; + } + + public static ProbeRuleSidecar verified(long sourceRevision, byte[] sourceDigest, + String algorithm, String converterVersion, + byte[] canonicalRules, byte[] sidecarDigest) { + if (sourceRevision <= 0L) { + throw new IllegalArgumentException("sourceRevision must be positive"); + } + byte[] sourceDigestCopy = requireDigest(sourceDigest, "sourceDigest"); + if (!ALGORITHM_ID.equals(algorithm)) { + throw new IllegalArgumentException("unsupported probe algorithm"); + } + if (converterVersion == null || !CONVERTER_ID.matcher(converterVersion).matches()) { + throw new IllegalArgumentException("invalid converterVersion"); + } + Objects.requireNonNull(canonicalRules, "canonicalRules"); + if (canonicalRules.length == 0 || canonicalRules.length > AdAudioRuleStore.MAX_IMPORT_BYTES) { + throw new IllegalArgumentException("probe rules are empty or too large"); + } + byte[] canonicalRulesCopy = canonicalRules.clone(); + byte[] sidecarDigestCopy = requireDigest(sidecarDigest, "sidecarDigest"); + if (!MessageDigest.isEqual(sha256(canonicalRulesCopy), sidecarDigestCopy)) { + throw new IllegalArgumentException("probe sidecar digest mismatch"); + } + return new ProbeRuleSidecar(sourceRevision, sourceDigestCopy, algorithm, + converterVersion, canonicalRulesCopy, sidecarDigestCopy); + } + + public void requireBoundTo(long revision, byte[] payloadDigest) { + if (revision != sourceRevision + || !MessageDigest.isEqual(sourceDigest, requireDigest(payloadDigest, "payloadDigest"))) { + throw new IllegalArgumentException("probe sidecar is not bound to the signed payload"); + } + } + + public long sourceRevision() { + return sourceRevision; + } + + public byte[] sourceDigest() { + return sourceDigest.clone(); + } + + public String algorithm() { + return algorithm; + } + + public String converterVersion() { + return converterVersion; + } + + public byte[] canonicalRules() { + return canonicalRules.clone(); + } + + public byte[] sidecarDigest() { + return sidecarDigest.clone(); + } + + private static byte[] requireDigest(byte[] digest, String name) { + if (digest == null || digest.length != DIGEST_BYTES) { + throw new IllegalArgumentException(name + " must be 32 bytes"); + } + return digest.clone(); + } + + private static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecarTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecarTest.java new file mode 100644 index 0000000000..2d91b1618c --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecarTest.java @@ -0,0 +1,117 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +public class ProbeRuleSidecarTest { + + private static final long REVISION = 8L; + + @Test + public void validSidecarBindsToSignedPayload() throws Exception { + byte[] sourceDigest = digest("signed-v2"); + byte[] canonicalRules = rules(); + ProbeRuleSidecar sidecar = sidecar(sourceDigest, canonicalRules); + + sidecar.requireBoundTo(REVISION, sourceDigest); + + assertEquals(REVISION, sidecar.sourceRevision()); + assertEquals(ProbeRuleSidecar.ALGORITHM_ID, sidecar.algorithm()); + assertEquals("converter-1", sidecar.converterVersion()); + assertArrayEquals(canonicalRules, sidecar.canonicalRules()); + } + + @Test + public void bindingRejectsRevisionMismatch() throws Exception { + byte[] sourceDigest = digest("signed-v2"); + ProbeRuleSidecar sidecar = sidecar(sourceDigest, rules()); + + assertThrows(IllegalArgumentException.class, + () -> sidecar.requireBoundTo(REVISION + 1L, sourceDigest)); + } + + @Test + public void bindingRejectsSourceDigestMismatch() throws Exception { + ProbeRuleSidecar sidecar = sidecar(digest("signed-v2"), rules()); + + assertThrows(IllegalArgumentException.class, + () -> sidecar.requireBoundTo(REVISION, digest("other-v2"))); + } + + @Test + public void verifiedRejectsSidecarDigestMismatch() throws Exception { + byte[] canonicalRules = rules(); + + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleSidecar.verified(REVISION, digest("signed-v2"), + ProbeRuleSidecar.ALGORITHM_ID, "converter-1", canonicalRules, + digest("different-sidecar"))); + } + + @Test + public void verifiedRejectsUnsupportedAlgorithm() throws Exception { + byte[] canonicalRules = rules(); + + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleSidecar.verified(REVISION, digest("signed-v2"), + "spectral-sequence-v2", "converter-1", canonicalRules, + digest(canonicalRules))); + } + + @Test + public void verifiedRejectsOversizedCanonicalRules() throws Exception { + byte[] oversized = new byte[AdAudioRuleStore.MAX_IMPORT_BYTES + 1]; + + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleSidecar.verified(REVISION, digest("signed-v2"), + ProbeRuleSidecar.ALGORITHM_ID, "converter-1", oversized, + digest(oversized))); + } + + @Test + public void byteArraysAreDefensivelyCopied() throws Exception { + byte[] sourceDigest = digest("signed-v2"); + byte[] canonicalRules = rules(); + byte[] sidecarDigest = digest(canonicalRules); + ProbeRuleSidecar sidecar = ProbeRuleSidecar.verified(REVISION, sourceDigest, + ProbeRuleSidecar.ALGORITHM_ID, "converter-1", canonicalRules, sidecarDigest); + + sourceDigest[0] ^= 1; + canonicalRules[0] ^= 1; + sidecarDigest[0] ^= 1; + + byte[] first = sidecar.canonicalRules(); + byte[] second = sidecar.canonicalRules(); + assertNotSame(first, second); + assertArrayEquals(rules(), first); + assertArrayEquals(digest("signed-v2"), sidecar.sourceDigest()); + assertArrayEquals(digest(rules()), sidecar.sidecarDigest()); + } + + private static ProbeRuleSidecar sidecar(byte[] sourceDigest, byte[] canonicalRules) + throws Exception { + return ProbeRuleSidecar.verified(REVISION, sourceDigest, + ProbeRuleSidecar.ALGORITHM_ID, "converter-1", canonicalRules, + digest(canonicalRules)); + } + + private static byte[] rules() { + return ("{\"format\":\"ad-audio-probe-rules\",\"schemaVersion\":1," + + "\"revision\":8,\"rules\":[]}").getBytes(StandardCharsets.UTF_8); + } + + private static byte[] digest(String value) throws Exception { + return digest(value.getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] digest(byte[] value) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(value); + } +}