diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..44cb754 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: en-US +reviews: + profile: assertive + auto_review: + enabled: true + drafts: true + base_branches: + - main + request_changes_workflow: false + path_filters: + - "!**/target/**" + - "!**/build/**" + - "!**/node_modules/**" +chat: + auto_reply: false +knowledge_base: + opt_out: true diff --git a/build.gradle.kts b/build.gradle.kts index 0014394..b1b480c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,7 +31,7 @@ repositories { dependencies { // BouncyCastle: trusted, deterministic Ed25519 (RFC 8032) implementation. - api("org.bouncycastle:bcprov-jdk18on:1.78.1") + api("org.bouncycastle:bcprov-jdk18on:1.86") testImplementation(platform("org.junit:junit-bom:5.10.2")) testImplementation("org.junit.jupiter:junit-jupiter") diff --git a/src/main/java/io/vibetensor/attestix/Canonicalizer.java b/src/main/java/io/vibetensor/attestix/Canonicalizer.java index a595686..49524de 100644 --- a/src/main/java/io/vibetensor/attestix/Canonicalizer.java +++ b/src/main/java/io/vibetensor/attestix/Canonicalizer.java @@ -28,7 +28,7 @@ * *

Divergence from strict RFC 8785 that callers MUST honour: RFC 8785 does * not normalize Unicode, but Attestix applies NFC. Non-whole floats are - * formatted via Python's {@code repr}, not ECMAScript Ryū - so signed payloads + * formatted via Python's {@code repr}, not ECMAScript Ryu - so signed payloads * should avoid non-trivial floats. The conformance vectors only use integers and * {@code 1.5}, on which all ports agree. */ @@ -83,6 +83,9 @@ private static void writeObject(StringBuilder sb, Map map) { TreeMap sorted = new TreeMap<>(Canonicalizer::compareByCodePoint); for (Map.Entry e : map.entrySet()) { String key = nfc(String.valueOf(e.getKey())); + if (sorted.containsKey(key)) { + throw new IllegalArgumentException("Duplicate normalized object key"); + } sorted.put(key, e.getValue()); } sb.append('{'); @@ -114,7 +117,7 @@ private static void writeArray(StringBuilder sb, List list) { /** * Write a string with NFC normalization and Python {@code json.dumps}-compatible - * escaping: only {@code "}, {@code \\}, and the control chars U+0000–U+001F are + * escaping: only {@code "}, {@code \\}, and the control chars U+0000-U+001F are * escaped (short escapes for b/t/n/f/r, {@code \\uXXXX} otherwise). With * {@code ensure_ascii=False}, every other character - including all non-ASCII - * is emitted raw (UTF-8 on encode). {@code /} is NOT escaped. diff --git a/src/main/java/io/vibetensor/attestix/CredentialVerifier.java b/src/main/java/io/vibetensor/attestix/CredentialVerifier.java index c799574..7eb908d 100644 --- a/src/main/java/io/vibetensor/attestix/CredentialVerifier.java +++ b/src/main/java/io/vibetensor/attestix/CredentialVerifier.java @@ -69,9 +69,9 @@ public static VerificationResult verifyCredential(Map vc, byte[] } boolean signatureValid = false; - if (proofValue != null) { - byte[] canonical = Canonicalizer.canonicalize(signedPayload); + if (proofValue != null && issuerPublicKey != null && issuerPublicKey.length == 32) { try { + byte[] canonical = Canonicalizer.canonicalize(signedPayload); byte[] sig = Base64Url.decode(proofValue); signatureValid = Ed25519.verify(issuerPublicKey, canonical, sig); } catch (RuntimeException ex) { @@ -79,7 +79,7 @@ public static VerificationResult verifyCredential(Map vc, byte[] } } - boolean notExpired = checkNotExpired(vc.get("expirationDate"), now); + boolean notExpired = !vc.containsKey("expirationDate") || checkNotExpired(vc.get("expirationDate"), now); boolean notRevoked = checkNotRevoked(vc.get("credentialStatus")); return new VerificationResult(signatureValid, notExpired, notRevoked, structureValid); @@ -88,7 +88,7 @@ public static VerificationResult verifyCredential(Map vc, byte[] private static boolean checkNotExpired(Object expirationDate, Instant now) { if (!(expirationDate instanceof String) || ((String) expirationDate).isEmpty()) { // No expiry => never expires. - return true; + return false; } try { Instant exp = OffsetDateTime.parse((String) expirationDate).toInstant(); diff --git a/src/test/java/io/vibetensor/attestix/SecurityBoundariesTest.java b/src/test/java/io/vibetensor/attestix/SecurityBoundariesTest.java new file mode 100644 index 0000000..ad66a91 --- /dev/null +++ b/src/test/java/io/vibetensor/attestix/SecurityBoundariesTest.java @@ -0,0 +1,28 @@ +package io.vibetensor.attestix; + +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class SecurityBoundariesTest { + @Test void ambiguousNormalizedKeysAreRejected() { + Map value = new LinkedHashMap<>(); + value.put("\u00e9",1); value.put("e\u0301",2); + assertThrows(IllegalArgumentException.class, () -> Attestix.canonicalize(value)); + } + @Test void malformedExpiryFailsClosed() { + for (Object expiry : new Object[]{null,false,123,"","invalid"}) { + Map vc = new LinkedHashMap<>(); + vc.put("proof",Map.of("proofValue","invalid")); vc.put("expirationDate",expiry); + assertFalse(Attestix.verifyCredential(vc,new byte[32],Instant.now()).notExpired()); + } + } + @Test void malformedKeyFailsClosed() { + assertFalse(Attestix.verifyCredential(Map.of("proof",Map.of("proofValue","AQ==")),new byte[]{1},Instant.now()).verify()); + } + @Test void missingExpiryRemainsUnlimited() { + assertTrue(Attestix.verifyCredential(Map.of("proof",Map.of("proofValue","AQ==")),new byte[32],Instant.now()).notExpired()); + } +}