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());
+ }
+}