Skip to content

Commit 623a243

Browse files
committed
isomer-go: refactor to cleaner workflow and add documentation
Codex authored this with prompting and review by Kent Bull
1 parent f71fb34 commit 623a243

22 files changed

Lines changed: 1442 additions & 308 deletions

apps/isomer-go/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ Minimal Go verifier sidecar for external W3C acceptance.
44

55
This app verifies the same VC-JWT and VP-JWT artifacts as `apps/isomer-node`,
66
but uses the local sibling `../vc-go` clone as the independent Go W3C stack.
7+
The Go sidecar depends on:
8+
9+
- `vc-go` for VC/VP parsing and Data Integrity verification
10+
- `did-go` for DID document parsing
11+
- the HTTP `did-webs-resolver` service for latest-key DID resolution
12+
713
The module uses:
814

915
```go
@@ -38,6 +44,6 @@ ACDC/W3C equivalence remain Python Isomer verifier responsibilities.
3844

3945
VP-JWT verification is intentionally split: the sidecar validates the VP JOSE
4046
signature and JWT claims directly, parses the VP with `vc-go/verifiable`, and
41-
then verifies each nested VC-JWT through the same VC path. VP JSON-LD checks are
42-
disabled in this pass because the current local `vc-go` stack is not the source
43-
of truth for Isomer's VP Data Integrity model.
47+
then verifies each nested VC-JWT through the same VC path. VP JSON-LD checks
48+
remain disabled in this pass because the current local `vc-go` stack is not yet
49+
the source of truth for Isomer's VP Data Integrity model.
Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
// Package main is the process entrypoint for the isomer-go sidecar.
2+
//
3+
// The real CLI contract lives in the internal sidecar serve runner. This file
4+
// stays intentionally small so process startup and signal handling remain easy
5+
// to audit.
16
package main
27

38
import (
49
"context"
5-
"flag"
610
"log"
711
"os"
812
"os/signal"
@@ -12,29 +16,10 @@ import (
1216
)
1317

1418
func main() {
15-
host := flag.String("host", "127.0.0.1", "HTTP host")
16-
port := flag.Int("port", 8788, "HTTP port")
17-
resolverURL := flag.String("resolver-url", "", "did:webs resolver base URL")
18-
resourceRoot := flag.String("resource-root", ".", "w3c-crosswalk repository root")
19-
flag.Parse()
20-
21-
if *resolverURL == "" {
22-
log.Fatal("--resolver-url is required")
23-
}
24-
2519
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
2620
defer stop()
2721

28-
server, err := sidecar.NewServer(sidecar.Config{
29-
Host: *host,
30-
Port: *port,
31-
ResolverURL: *resolverURL,
32-
ResourceRoot: *resourceRoot,
33-
})
34-
if err != nil {
35-
log.Fatal(err)
36-
}
37-
if err = server.Run(ctx); err != nil {
22+
if err := sidecar.RunServe(ctx, os.Args[1:]); err != nil {
3823
log.Fatal(err)
3924
}
4025
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package sidecar
2+
3+
import "errors"
4+
5+
// validateVCClaims enforces the local VC-JWT claim relationships the sidecar
6+
// expects before deeper cryptographic checks run.
7+
func validateVCClaims(jwtPayload, vc map[string]any) error {
8+
if err := requireOptionalClaimMatch(
9+
asString(jwtPayload["iss"]),
10+
asString(vc["issuer"]),
11+
"JWT iss does not match vc.issuer",
12+
); err != nil {
13+
return err
14+
}
15+
if err := requireOptionalClaimMatch(
16+
asString(jwtPayload["jti"]),
17+
asString(vc["id"]),
18+
"JWT jti does not match vc.id",
19+
); err != nil {
20+
return err
21+
}
22+
23+
if subject := asMap(vc["credentialSubject"]); subject != nil {
24+
if err := requireOptionalClaimMatch(
25+
asString(jwtPayload["sub"]),
26+
asString(subject["id"]),
27+
"JWT sub does not match credentialSubject.id",
28+
); err != nil {
29+
return err
30+
}
31+
}
32+
33+
if err := requireNumericClaim(jwtPayload, "iat", "VC-JWT requires numeric iat"); err != nil {
34+
return err
35+
}
36+
if err := requireNumericClaim(jwtPayload, "nbf", "VC-JWT requires numeric nbf"); err != nil {
37+
return err
38+
}
39+
return nil
40+
}
41+
42+
// validateVPClaims enforces the local VP-JWT claim relationships the sidecar
43+
// expects before nested verification begins.
44+
func validateVPClaims(jwtPayload, vp map[string]any, audience, nonce string) error {
45+
if err := requireOptionalClaimMatch(
46+
asString(jwtPayload["iss"]),
47+
asString(vp["holder"]),
48+
"JWT iss does not match vp.holder",
49+
); err != nil {
50+
return err
51+
}
52+
if err := requireOptionalClaimMatch(
53+
asString(jwtPayload["aud"]),
54+
audience,
55+
"JWT aud does not match expected audience",
56+
); err != nil {
57+
return err
58+
}
59+
if err := requireOptionalClaimMatch(
60+
asString(jwtPayload["nonce"]),
61+
nonce,
62+
"JWT nonce does not match expected nonce",
63+
); err != nil {
64+
return err
65+
}
66+
return requireNumericClaim(jwtPayload, "iat", "VP-JWT requires numeric iat")
67+
}
68+
69+
// requireOptionalClaimMatch fails only when the expected value is present and
70+
// the actual value does not match it.
71+
func requireOptionalClaimMatch(actual, expected, message string) error {
72+
if claimMatchesExpected(actual, expected) {
73+
return nil
74+
}
75+
return errors.New(message)
76+
}
77+
78+
// claimMatchesExpected implements the sidecar's "empty expected means optional"
79+
// matching rule for JWT claim checks.
80+
func claimMatchesExpected(actual, expected string) bool {
81+
return expected == "" || actual == expected
82+
}
83+
84+
// requireNumericClaim enforces that one decoded JWT payload claim was parsed as
85+
// a JSON number.
86+
func requireNumericClaim(payload map[string]any, claim, message string) error {
87+
if _, ok := payload[claim].(float64); ok {
88+
return nil
89+
}
90+
return errors.New(message)
91+
}

apps/isomer-go/internal/sidecar/context_loader.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,27 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"sync"
89

910
"github.com/piprate/json-gold/ld"
1011
)
1112

13+
// localDocumentLoader pins the small set of JSON-LD contexts used by Isomer's
14+
// VC projection so verification never depends on remote context fetches.
1215
type localDocumentLoader struct {
1316
root string
17+
mu sync.RWMutex
1418
cache map[string]any
1519
}
1620

21+
// newLocalDocumentLoader builds one pinned local JSON-LD loader rooted at the
22+
// Python Isomer resource tree.
1723
func newLocalDocumentLoader(root string) *localDocumentLoader {
1824
return &localDocumentLoader{root: root, cache: map[string]any{}}
1925
}
2026

27+
// LoadDocument returns one pinned JSON-LD context from disk and caches it for
28+
// later reuse.
2129
func (l *localDocumentLoader) LoadDocument(url string) (*ld.RemoteDocument, error) {
2230
filename, ok := map[string]string{
2331
"https://www.w3.org/2018/credentials/v1": "vc-v1.jsonld",
@@ -27,20 +35,33 @@ func (l *localDocumentLoader) LoadDocument(url string) (*ld.RemoteDocument, erro
2735
if !ok {
2836
return nil, fmt.Errorf("no local JSON-LD context registered for %s", url)
2937
}
30-
if _, ok = l.cache[url]; !ok {
38+
39+
l.mu.RLock()
40+
document, ok := l.cache[url]
41+
l.mu.RUnlock()
42+
if !ok {
3143
path := filepath.Join(l.root, "src", "vc_isomer", "resources", "contexts", filename)
3244
body, err := os.ReadFile(path)
3345
if err != nil {
3446
return nil, err
3547
}
36-
var document any
37-
if err = json.Unmarshal(body, &document); err != nil {
48+
49+
var loaded any
50+
if err = json.Unmarshal(body, &loaded); err != nil {
3851
return nil, err
3952
}
40-
l.cache[url] = document
53+
54+
l.mu.Lock()
55+
if cached, exists := l.cache[url]; exists {
56+
document = cached
57+
} else {
58+
l.cache[url] = loaded
59+
document = loaded
60+
}
61+
l.mu.Unlock()
4162
}
4263
return &ld.RemoteDocument{
4364
DocumentURL: url,
44-
Document: l.cache[url],
65+
Document: document,
4566
}, nil
4667
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Contract tests for the pinned local JSON-LD context loader.
2+
package sidecar
3+
4+
import (
5+
"os"
6+
"path/filepath"
7+
"sync"
8+
"testing"
9+
)
10+
11+
func TestLocalDocumentLoaderConcurrentAccess(t *testing.T) {
12+
root := t.TempDir()
13+
contextPath := filepath.Join(root, "src", "vc_isomer", "resources", "contexts")
14+
if err := os.MkdirAll(contextPath, 0o755); err != nil {
15+
t.Fatal(err)
16+
}
17+
if err := os.WriteFile(filepath.Join(contextPath, "vc-v1.jsonld"), []byte(`{"@context":"vc"}`), 0o644); err != nil {
18+
t.Fatal(err)
19+
}
20+
21+
loader := newLocalDocumentLoader(root)
22+
var waitGroup sync.WaitGroup
23+
24+
for index := 0; index < 16; index++ {
25+
waitGroup.Add(1)
26+
go func() {
27+
defer waitGroup.Done()
28+
document, err := loader.LoadDocument("https://www.w3.org/2018/credentials/v1")
29+
if err != nil {
30+
t.Error(err)
31+
return
32+
}
33+
if document.DocumentURL != "https://www.w3.org/2018/credentials/v1" {
34+
t.Errorf("unexpected document %#v", document)
35+
}
36+
}()
37+
}
38+
39+
waitGroup.Wait()
40+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Package sidecar implements the Go external verifier used by Isomer's W3C
2+
// acceptance tests.
3+
//
4+
// The package owns only the narrow W3C-facing verification pipeline:
5+
// decoding VC-JWTs and VP-JWTs, checking local claim relationships, resolving
6+
// current did:webs key state through the HTTP resolver service, verifying
7+
// embedded Data Integrity proofs, consulting projected credential status, and
8+
// recursively checking nested VC-JWTs inside VP-JWTs.
9+
//
10+
// It does not attempt Python Isomer's TEL-aware ACDC/W3C pair verification.
11+
package sidecar

apps/isomer-go/internal/sidecar/jwt.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@ import (
99
"strings"
1010
)
1111

12+
// jwtParts stores the structurally decoded pieces of one compact JWT.
1213
type jwtParts struct {
1314
Header map[string]any
1415
Payload map[string]any
1516
SigningInput []byte
1617
Signature []byte
1718
}
1819

20+
// decodeJWT parses one compact JWT into its decoded header, payload, signing
21+
// input, and detached signature bytes.
1922
func decodeJWT(token string) (*jwtParts, error) {
2023
parts := strings.Split(token, ".")
2124
if len(parts) != 3 {
@@ -52,14 +55,16 @@ func decodeJWT(token string) (*jwtParts, error) {
5255
}, nil
5356
}
5457

58+
// verifyJWTSignature checks an EdDSA compact JWT signature against one resolved
59+
// Ed25519 JWK.
5560
func verifyJWTSignature(parts *jwtParts, jwk map[string]any) error {
5661
if parts.Header["alg"] != "EdDSA" {
5762
return fmt.Errorf("unsupported alg: %v", parts.Header["alg"])
5863
}
59-
x, ok := jwk["x"].(string)
60-
if !ok || x == "" {
64+
if !isEd25519JWK(jwk) {
6165
return errors.New("Ed25519 JWK is missing x")
6266
}
67+
x := jwk["x"].(string)
6368
key, err := base64.RawURLEncoding.DecodeString(x)
6469
if err != nil {
6570
return fmt.Errorf("decode Ed25519 JWK x: %w", err)
@@ -70,16 +75,25 @@ func verifyJWTSignature(parts *jwtParts, jwk map[string]any) error {
7075
return nil
7176
}
7277

78+
// asMap narrows one generic decoded JSON value to a plain object.
7379
func asMap(value any) map[string]any {
7480
if typed, ok := value.(map[string]any); ok {
7581
return typed
7682
}
7783
return nil
7884
}
7985

86+
// asString returns the string form of a decoded JSON value when available.
8087
func asString(value any) string {
8188
if typed, ok := value.(string); ok {
8289
return typed
8390
}
8491
return ""
8592
}
93+
94+
// isEd25519JWK checks whether the sidecar has enough JWK material to perform
95+
// local Ed25519 signature verification.
96+
func isEd25519JWK(jwk map[string]any) bool {
97+
x := asString(jwk["x"])
98+
return x != ""
99+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package sidecar
2+
3+
import "github.com/trustbloc/vc-go/dataintegrity/models"
4+
5+
// ProofOptions aliases the trustbloc proof options type so internal seams can
6+
// depend on a small local name.
7+
type ProofOptions = models.ProofOptions
8+
9+
// wrapProofVerifier adapts the concrete trustbloc verifier to the local proof
10+
// interface used by the verifier runtime.
11+
type wrapProofVerifier struct {
12+
Verifier interface {
13+
VerifyProof(doc []byte, opts *models.ProofOptions) error
14+
}
15+
}
16+
17+
// VerifyProof forwards one proof verification request to the wrapped trustbloc
18+
// verifier.
19+
func (w wrapProofVerifier) VerifyProof(doc []byte, opts *ProofOptions) error {
20+
return w.Verifier.VerifyProof(doc, opts)
21+
}

0 commit comments

Comments
 (0)