diff --git a/CANONICAL.md b/CANONICAL.md index fb39f6d..274411d 100644 --- a/CANONICAL.md +++ b/CANONICAL.md @@ -1,53 +1,49 @@ -# Attestix canonical form (Java port notes) - -The Attestix canonical form is **JCS-*style*, NOT strict RFC 8785.** This port -reproduces, byte-for-byte, the output of the Python reference -`attestix/auth/crypto.py::canonicalize_json` (attestix 0.4.0). The vectors' -`canonical_bytes_hex` is the oracle. - -## The rule - -``` -canonical_bytes = NFC_normalize_recursive(obj) - |> json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False) - |> encode("utf-8") -``` - -with whole-number floats collapsed to integers. - -## Exact behaviours this port implements - -1. **NFC normalization**: every string **value** and every object **key** is - `Normalizer.normalize(s, Form.NFC)` before serialization. (`"cafe"+U+0301` β†’ - `cafΓ©`.) This is the main divergence from RFC 8785, which does *not* normalize. -2. **Key sorting**: object keys sorted by Unicode **code point** ascending, - recursively. NFC is applied to keys *before* sorting. -3. **Separators**: `,` between items, `:` between key and value. No whitespace. -4. **Raw UTF-8, no `\uXXXX`**: non-ASCII characters are emitted as raw UTF-8 - bytes (`ensure_ascii=False`). `Γ©` = `c3 a9`, `β˜ƒ` = `e2 98 83`, `πŸ˜€` = - `f0 9f 98 80`. -5. **Whole-number floats β†’ integers**: `1.0` β†’ `1`, `2.00` β†’ `2`. A non-whole - float keeps its value: `1.5` β†’ `1.5`. (Implemented via `BigDecimal`: - `stripTrailingZeros().scale() <= 0` means whole.) -6. **Integers stay integers, arbitrary precision**: `9007199254740993` is - serialized exactly. This port parses integral JSON numbers into - `java.math.BigInteger`, so there is no `2^53` precision loss. -7. **Control-char escaping**: only `"`, `\`, and U+0000 to U+001F are escaped, - using `\b \t \n \f \r` where applicable and `\uXXXX` otherwise. `/` is **not** - escaped. This matches Python's `json.dumps`. - -## Float caveat - -For non-trivial floating-point values, Python `json.dumps` and ECMAScript RyΕ« can -disagree. The conformance vectors only use integers and `1.5`, on which all ports -agree. **Signed payloads should avoid non-trivial floats** (e.g. `0.1 + 0.2`). - -## Signing & encoding - -- **VC**: signed payload is the credential minus `proof` and `credentialStatus`, - canonicalized as above, Ed25519-signed; `proof.proofValue` is base64url **with - padding**. -- **UCAN**: EdDSA JWTs (compact form). The signed message is - `base64url(header) + "." + base64url(payload)` (base64url **without** padding, - per the JWT spec). Only `alg=EdDSA` is accepted. -- **did:key**: `did:key:z` + base58btc(`0xed 0x01` || raw-32-byte key). +# Attestix canonical form in Java + +The implementation follows the Attestix v1 JCS-style signing form for the +represented conformance cases. It is not strict RFC 8785. The shared +[test vectors](testdata/vectors.json) are the compatibility reference. + +## Implemented rules + +1. NFC-normalize each string value and object key. +2. Reject distinct object keys that become identical after NFC normalization. +3. Sort the normalized keys recursively by Unicode code point. +4. Use compact separators, with no formatting whitespace. +5. Emit non-ASCII strings as UTF-8; escape quotes, backslashes, and control + characters using JSON escapes. +6. Parse integer tokens into `BigInteger`, preserving values such as + `9007199254740993` without JavaScript's number rounding. +7. Normalize `BigDecimal` values with `stripTrailingZeros`; whole values emit + integers and remaining decimal values use the implementation's decimal form. + +The public `Attestix.canonicalize(String)` first parses JSON. Malformed JSON +throws `JsonException`. Unsupported values or normalized-key collisions can +throw during canonicalization. Credential verification catches canonicalization +errors in its signature check and returns `signatureValid=false`. + +## Numeric and parsing limits + +The vectors exercise integers and `1.5`. The implementation uses `BigDecimal`, +not Python's binary floating-point serializer. Exact equivalence for arbitrary +fractions, exponents, and signed-zero values is not established by those vectors. +Prefer integer or string fields for signed data. Callers passing an already +parsed Map must also preserve numeric precision; conversion to binary floating +point can lose information before canonicalization starts. + +Collision rejection applies to distinct keys retained in the parsed object. +It is not a guarantee that identical duplicate names in raw JSON are detected +before the parser constructs its Map. + +## Signed fields and encodings + +- A VC signature covers the credential minus `proof` and `credentialStatus`. + The verifier uses the caller's trusted issuer key, not unsigned proof metadata. +- A UCAN signature covers the literal compact JWS signing input + `base64url(header) + "." + base64url(payload)`. It is not canonicalized JSON; + only `alg=EdDSA` is accepted. +- Ed25519 public keys contain 32 bytes and signatures contain 64 bytes. +- Attestix VC proofs use padded base64url. Delegation compact-JWT segments use + unpadded base64url. +- An Ed25519 `did:key` is `did:key:z` followed by base58btc of the multicodec + bytes `0xed 0x01` and the raw public key. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..60d200c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## Unreleased + +These changes describe main-branch source. They do not announce a Maven Central +release or imply that configured version 0.4.1 has been republished. + +### Verification + +- Reject distinct keys that collide after NFC normalization. +- Treat canonicalization failures and malformed pinned keys as invalid + signatures; reject malformed present credential expiry values. +- Update Bouncy Castle bcprov-jdk18on to 1.86. + +### Documentation + +- Explain caller-pinned issuer trust, input exceptions, offline revocation, and + the explicit pair versus recursive delegation checks. +- Document JDK 17 builds, Java 11 output, and bounded canonical numeric support. diff --git a/README.md b/README.md index 834fb09..48b9284 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,120 @@ # Attestix Java Verifier -Offline verifier for **Attestix**-issued credentials, in pure Java. Verify -Ed25519 [W3C Verifiable Credentials](https://www.w3.org/TR/vc-data-model/) and -[UCAN](https://github.com/ucan-wg/spec) delegation chains that were issued by the -[Attestix](https://github.com/VibeTensor/attestix) Python core (**with no Python -runtime needed**). - -This is a language port of the Attestix verifier. It reproduces the Attestix -canonical form **byte-for-byte** and is validated against the shared -cross-language conformance vectors (`spec/verify/v1/vectors.json` in the parent -repo, vendored here under `testdata/` and `src/test/resources/`). - -This port is **verifier-only**: it verifies credentials and delegation chains; -it does not issue them. Issuance stays in the Attestix Python core -(`pip install attestix`) or Attestix Cloud. - -- Parent project: -- Bundle wire-format spec: -- Ed25519 via [BouncyCastle](https://www.bouncycastle.org/) (trusted, deterministic) - -> **Canonical form note.** Attestix uses a **JCS-*style*** canonical form, **not -> strict RFC 8785.** The load-bearing divergence: Attestix applies Unicode **NFC -> normalization** to every string value and object key (RFC 8785 does not), and -> whole-number floats collapse to integers (`1.0` β†’ `1`). See -> [`CANONICAL.md`](./CANONICAL.md) for the full rules. - -## Install - -Maven (once published to Maven Central): - -```xml - - com.vibetensor - attestix - 0.4.1 - -``` +Offline Java verifier for Attestix Ed25519 credentials and UCAN-style +compact JWT delegation chains. It needs no Python runtime and makes no network +calls. It does not issue credentials or provide a presentation-verification API. +See the [Attestix core](https://github.com/VibeTensor/attestix) for issuance. + +## Build the current source -Gradle (Kotlin DSL): +Build with JDK 17; source and bytecode target Java 11 via `--release 11`. +The checkout uses Bouncy Castle `bcprov-jdk18on` 1.86, as declared in +[build.gradle.kts](build.gradle.kts). -```kotlin -implementation("com.vibetensor:attestix:0.4.1") +```sh +./gradlew test --no-daemon +./gradlew build --no-daemon ``` -Requires Java 11+ at runtime. (CI builds and tests on Temurin 17.) +The configured publication coordinates are `com.vibetensor:attestix:0.4.1`. +These coordinates do not establish Maven Central availability or publication of +main-branch fixes. Build a reviewed checkout or confirm the contents of the +release you select before depending on its verification behavior. -## Verify a credential (10 lines) +## Verify a credential with a trusted key ```java -import io.vibetensor.attestix.*; +import io.vibetensor.attestix.Attestix; +import io.vibetensor.attestix.DidKey; +import io.vibetensor.attestix.VerificationResult; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Instant; -// The issuer's did:key (published out of band, e.g. in the agent card). -byte[] issuerKey = DidKey.decode("did:key:z6Mko5TBPGKHkCxSgmf3aC6p6SGj2auwCfRmBydXJFEwL4ev"); - -String vcJson = /* the credential JSON, e.g. read from a file */ "..."; -VerificationResult r = Attestix.verifyCredential(vcJson, issuerKey, Instant.now()); - -System.out.println("signature valid: " + r.signatureValid()); -System.out.println("not expired: " + r.notExpired()); -System.out.println("not revoked: " + r.notRevoked()); -System.out.println("overall verify: " + r.verify()); // AND of all checks -``` - -## Verify a delegation chain - -```java -byte[] serverKey = DidKey.decode("did:key:z6Mko5TBPGKHkCxSgmf3aC6p6SGj2auwCfRmBydXJFEwL4ev"); -DelegationResult d = Attestix.verifyDelegationChain(parentJwt, childJwt, serverKey, Instant.now()); -// d.verify() == every JWT signature valid AND child att βŠ† parent att AND not expired +public final class VerifyExample { + public static void main(String[] args) throws Exception { + if (args.length != 1) { + throw new IllegalArgumentException("usage: VerifyExample credential.json"); + } + // Obtain and authenticate the expected issuer's key out of band. + byte[] issuerKey = DidKey.decode( + "did:key:z6Mko5TBPGKHkCxSgmf3aC6p6SGj2auwCfRmBydXJFEwL4ev"); + String vcJson = Files.readString(Path.of(args[0])); + VerificationResult result = Attestix.verifyCredential(vcJson, issuerKey, Instant.now()); + System.out.println("valid=" + result.verify()); + } +} ``` -UCAN delegations are EdDSA JWTs (compact form), **not** JCS-signed. Only -`alg=EdDSA` is accepted (`alg:none` is rejected). Capability attenuation is -enforced: a child's capabilities must be a subset of its parent's. - -## Decode a did:key +This sample key is an example, not a trust anchor for arbitrary credentials. +The caller supplies the trusted 32-byte issuer key. Verification does not +resolve a key from `proof.verificationMethod` or independently check that the +pinned key corresponds to the document's issuer identifier. Make that trust +association in the application. + +## Credential behavior and errors + +- `VerificationResult` exposes `signatureValid()`, `notExpired()`, + `notRevoked()`, `structureValid()`, and their combined `verify()` verdict. +- The signed payload excludes only top-level `proof` and `credentialStatus`. + NFC-normalized key collisions make signature verification fail. +- Null or wrong-length pinned keys, invalid proof encoding, and canonicalization + errors do not pass signature verification. +- Absent `expirationDate` means no expiry. A present null, non-string, empty, or + unparseable value fails the expiry check. Valid expiry requires `now < expiry`. +- Embedded revocation status is read locally. Missing status does not establish + current revocation state; consult a trusted issuer status source separately. +- Malformed JSON can throw `JsonException`; catch it at the input boundary. + Direct canonicalization can also throw for unsupported input. Do not interpret + any exception as a successful verification. +- Structure checks concern the proof shape, not complete W3C schema validation. + This verifier does not perform JSON-LD processing or legal assessment. + +## Delegation verification ```java -byte[] rawEd25519 = Attestix.decodeDidKey("did:key:z6Mko5TBPGKHkCxSgmf3aC6p6SGj2auwCfRmBydXJFEwL4ev"); -// rawEd25519 is the 32-byte public key (multibase base58btc + 0xed01 multicodec prefix) +DelegationResult result = Attestix.verifyDelegationChain( + parentJwt, childJwt, trustedServerKey, Instant.now()); +boolean valid = result.verify(); ``` -## Canonicalize JSON (the Attestix way) - -```java -byte[] canonical = Attestix.canonicalize("{\"b\":2,\"a\":1}"); -// -> bytes of {"a":1,"b":2} (keys sorted by code point, no whitespace, raw UTF-8, NFC) -``` +Import `io.vibetensor.attestix.DelegationResult` for this fragment. The caller +supplies the trusted server key used for every signature. Verification checks +EdDSA signatures, parent/child expiry, the child's recursive `prf` signatures +and expiry, repeated-`jti` cycles, and capability subset between the explicit +parent and child. It does not independently enforce capability attenuation at +every recursive ancestor, `nbf`, `iat`, audience policy, or live revocation. +Apply the required application policy before authorizing an operation. ## Public API | Method | Purpose | |---|---| -| `Attestix.verifyCredential(vcJson, issuerKey, now)` | Verify a W3C VC β†’ `VerificationResult` | -| `Attestix.verifyDelegationChain(parent, child, serverKey, now)` | Verify a UCAN chain β†’ `DelegationResult` | -| `Attestix.canonicalize(json)` | JCS-style canonical UTF-8 bytes | -| `Attestix.decodeDidKey(did)` | `did:key:z...` -> raw 32-byte Ed25519 key | +| `Attestix.verifyCredential(json, issuerKey, now)` | Verify a credential under the caller's trusted key. | +| `Attestix.verifyDelegationChain(parent, child, serverKey, now)` | Verify the explicit pair and the child's proof ancestry. | +| `Attestix.canonicalize(json)` | Produce canonical bytes or throw for invalid/unsupported input. | +| `Attestix.decodeDidKey(did)` | Decode an Ed25519 `did:key` to a 32-byte public key. | -`VerificationResult` exposes `signatureValid()`, `notExpired()`, `notRevoked()`, -`structureValid()`, and `verify()`. +## Canonical form and compatibility -## Build & test +See [CANONICAL.md](CANONICAL.md). Attestix uses NFC normalization, compact UTF-8 +JSON, normalized-key sorting, and arbitrary-precision integer handling; it is +not strict RFC 8785. General floating-point interoperability is not guaranteed. -```bash -./gradlew test # runs the conformance vectors -./gradlew build # jar + sources + javadoc -``` - -CI ([`.github/workflows/test.yml`](.github/workflows/test.yml)) builds and runs -the vectors on Temurin 17 with SHA-pinned actions. +The shared vectors are vendored in [testdata/vectors.json](testdata/vectors.json) +and [src/test/resources/vectors.json](src/test/resources/vectors.json). +[ConformanceVectorsTest.java](src/test/java/io/vibetensor/attestix/ConformanceVectorsTest.java) +checks those represented cases; [SecurityBoundariesTest.java](src/test/java/io/vibetensor/attestix/SecurityBoundariesTest.java) +covers the hardened input boundaries. This is not a guarantee for every JSON +value or every deployment policy. -## Conformance +## Release configuration -Every vector in `testdata/vectors.json` is asserted by -`src/test/java/io/vibetensor/attestix/ConformanceVectorsTest.java`: - -| Vector | What it checks | -|---|---| -| `canon-001` | JCS-style canonical bytes (NFC, sorted keys, whole-floatβ†’int, big int) | -| `didkey-001` | did:key decode (0xed01 multicodec, 32-byte key) + round-trip | -| `vc-valid-001` | Valid VC β†’ verify true | -| `vc-tampered-001` | Tampered claim β†’ signature invalid β†’ verify false | -| `vc-expired-001` | Expired VC β†’ not_expired false β†’ verify false | -| `ucan-chain-valid-001` | Delegation chain, child att βŠ† parent att β†’ verify true | -| `ucan-chain-escalation-002` | Privilege escalation (`admin` not in parent) β†’ verify false | - -## Releasing to Maven Central - -This build is **publish-ready** (POM metadata, sources + javadoc jars, GPG -signing wired) via the Sonatype **Central Portal** (`central.sonatype.com`). To -cut a release: - -1. Configure Central Portal token credentials - (`mavenCentralUsername`, `mavenCentralPassword`) and an in-memory GPG key - (`signingInMemoryKey`, `signingInMemoryKeyPassword`) as `ORG_GRADLE_PROJECT_*` - environment variables or Gradle properties. -2. Bump `version` in `build.gradle.kts`, commit, then push a matching `vX.Y.Z` - tag. The `publish` GitHub Actions workflow runs the conformance vectors and - then `./gradlew publishToMavenCentral`. -3. With `automaticRelease = true`, Central validates and releases the artifact - automatically (no manual staging step). Flip that flag to stage first. - -No tokens are committed; release runs only on a version tag with the credentials -supplied as encrypted GitHub Actions secrets. +The build configures sources/Javadoc artifacts, signing, and the Sonatype +Central Portal. Publication is a separate maintainer operation requiring +credentials and a reviewed version tag. Nothing in this README announces a new +registry release or authorizes publishing artifacts. ## License -[Apache-2.0](./LICENSE). See [`NOTICE`](./NOTICE). +[Apache-2.0](LICENSE). See [NOTICE](NOTICE).