Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 49 additions & 53 deletions CANONICAL.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
210 changes: 90 additions & 120 deletions README.md
Original file line number Diff line number Diff line change
@@ -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: <https://github.com/VibeTensor/attestix>
- Bundle wire-format spec: <https://attestix.io/spec/bundle/v1>
- 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
<dependency>
<groupId>com.vibetensor</groupId>
<artifactId>attestix</artifactId>
<version>0.4.1</version>
</dependency>
```
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).
Loading