Skip to content

Commit 1a7e33b

Browse files
committed
Merge branch 'feature/client-verify' into develop
2 parents 3ef4750 + 42efde2 commit 1a7e33b

4 files changed

Lines changed: 1086 additions & 0 deletions

File tree

client/verify/fetch.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// SPDX-FileCopyrightText: 2026 OpenWaymark contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package verify
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
)
13+
14+
// Fetcher retrieves the body of a GET request.
15+
//
16+
// The same interface runs three ways with three different implementations:
17+
// HTTPFetcher (net/http, this file) for ordinary Go code and tests,
18+
// a fetch()-backed one in the WASM build, and a fake one in this package's
19+
// own tests. VerifySubject and httpTrustSource know nothing about how a byte
20+
// arrives, only that it did — the trusted-local-data/remote-claim split
21+
// gossip.Client already draws elsewhere in this project (OWM-9 A11).
22+
type Fetcher interface {
23+
// Fetch retrieves url and returns the response body for a 2xx status.
24+
// A non-2xx status is returned as *APIError, decoded from the node's own
25+
// error body shape ({"error": "...", "detail": "..."}) where possible.
26+
Fetch(ctx context.Context, url string) ([]byte, error)
27+
}
28+
29+
// APIError reports a non-2xx response from a node's public API.
30+
type APIError struct {
31+
StatusCode int
32+
Code string // the node's own machine-readable error code, e.g. "erased", "not_found"
33+
Detail string
34+
}
35+
36+
func (e *APIError) Error() string {
37+
if e.Detail != "" {
38+
return fmt.Sprintf("owm/client/verify: %s (%d): %s", e.Code, e.StatusCode, e.Detail)
39+
}
40+
return fmt.Sprintf("owm/client/verify: %s (%d)", e.Code, e.StatusCode)
41+
}
42+
43+
// Erased reports whether this error is the node reporting that an entry's
44+
// payload was lawfully erased under Art. 17 GDPR (HTTP 410, code "erased") —
45+
// the one non-2xx response callers are expected to handle specially rather
46+
// than treat as a failure.
47+
func (e *APIError) Erased() bool { return e.Code == "erased" }
48+
49+
// HTTPFetcher is the default Fetcher, for ordinary (non-WASM) Go code: a CLI
50+
// verifier, tests, or any other caller that has a real net/http.Client.
51+
type HTTPFetcher struct {
52+
Client *http.Client // nil means http.DefaultClient
53+
}
54+
55+
func (f HTTPFetcher) httpClient() *http.Client {
56+
if f.Client != nil {
57+
return f.Client
58+
}
59+
return http.DefaultClient
60+
}
61+
62+
func (f HTTPFetcher) Fetch(ctx context.Context, u string) ([]byte, error) {
63+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
64+
if err != nil {
65+
return nil, fmt.Errorf("owm/client/verify: build request: %w", err)
66+
}
67+
resp, err := f.httpClient().Do(req)
68+
if err != nil {
69+
return nil, fmt.Errorf("owm/client/verify: %s: %w", u, err)
70+
}
71+
defer resp.Body.Close()
72+
73+
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
74+
if err != nil {
75+
return nil, fmt.Errorf("owm/client/verify: %s: read body: %w", u, err)
76+
}
77+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
78+
ae := &APIError{StatusCode: resp.StatusCode}
79+
var eb struct {
80+
Error string `json:"error"`
81+
Detail string `json:"detail"`
82+
}
83+
if json.Unmarshal(body, &eb) == nil {
84+
ae.Code, ae.Detail = eb.Error, eb.Detail
85+
}
86+
return nil, ae
87+
}
88+
return body, nil
89+
}

client/verify/trust.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// SPDX-FileCopyrightText: 2026 OpenWaymark contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package verify
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
11+
"openwaymark.org/owm/core"
12+
owmlog "openwaymark.org/owm/log"
13+
"openwaymark.org/owm/trust"
14+
)
15+
16+
// httpTrustSource implements trust.Source over the public API, mirroring
17+
// node/attestation.go's own logSource — same-issuer-only revocation
18+
// matching (OWM-6 §6), one history fetch per key — but fetching over HTTP
19+
// instead of reading the local log directly.
20+
//
21+
// Known scope limit, stated plainly rather than silently assumed away: every
22+
// fetch in a chain walk goes to the same base URL the top-level VerifySubject
23+
// call was given. An attestation chain that crosses node boundaries (an
24+
// accreditation body attesting an entity from a different node's log) is not
25+
// followed — the chain simply stops there, which trust.Compute treats as "no
26+
// further contribution," the same as a chain with no attestation at all.
27+
// Extending this to follow Options.LogURLs the way VerifySubject itself does
28+
// for parent references is future work, not solved here.
29+
//
30+
// Also unlike VerifySubject's own top-level entries, an attestation's own
31+
// signature and inclusion proof are not re-verified here — only its
32+
// commitment against its fetched payload. Re-verifying every hop of a trust
33+
// chain in full would mean fetching and checking an STH and inclusion proof
34+
// per attestation, not just per top-level entry; the commitment check alone
35+
// already rules out a payload that does not match what was actually
36+
// committed to, which is the failure mode a dishonest *node* (as opposed to
37+
// a dishonest attestation issuer, already covered by the signature check
38+
// baked into commitment-independent trust) could otherwise exploit.
39+
type httpTrustSource struct {
40+
c *client
41+
}
42+
43+
func (s *httpTrustSource) AttestationsOf(ctx context.Context, subject core.KeyID) ([]trust.Attestation, error) {
44+
hist, err := s.c.fetchHistory(ctx, core.SubjectID(subject))
45+
if err != nil {
46+
return nil, err
47+
}
48+
49+
type revocation struct {
50+
target core.Digest
51+
issuer core.KeyID
52+
}
53+
revoked := map[revocation]bool{}
54+
type parsed struct {
55+
id core.Digest
56+
e *core.Entry
57+
}
58+
var attestations []parsed
59+
for _, lv := range hist.Entries {
60+
leaf, err := owmlog.ParseLeaf(lv.Leaf)
61+
if err != nil {
62+
return nil, fmt.Errorf("owm/client/verify: trust source: parse leaf: %w", err)
63+
}
64+
se, err := leaf.SignedEntry()
65+
if err != nil {
66+
return nil, fmt.Errorf("owm/client/verify: trust source: parse entry: %w", err)
67+
}
68+
e, err := se.Entry()
69+
if err != nil {
70+
return nil, fmt.Errorf("owm/client/verify: trust source: decode entry: %w", err)
71+
}
72+
switch e.Type {
73+
case core.EntryTypeAttestation:
74+
attestations = append(attestations, parsed{id: leaf.EntryID(), e: e})
75+
case core.EntryTypeRevocation:
76+
if e.Target != nil {
77+
revoked[revocation{target: e.Target.Entry, issuer: e.Issuer}] = true
78+
}
79+
}
80+
}
81+
82+
out := make([]trust.Attestation, 0, len(attestations))
83+
for _, a := range attestations {
84+
salt, payload, err := s.c.fetchPayload(ctx, a.id)
85+
if err != nil {
86+
var ae *APIError
87+
if errors.As(err, &ae) && ae.Erased() {
88+
// The evidentiary basis is gone under Art. 17 GDPR; nothing
89+
// left to recompute a level from — no contribution, the
90+
// same as an attestation nobody issued (mirrors
91+
// node/attestation.go's logSource exactly).
92+
continue
93+
}
94+
return nil, err
95+
}
96+
if !core.VerifyCommitment(a.e.Commitment, salt, payload) {
97+
return nil, fmt.Errorf("owm/client/verify: trust source: attestation %s: payload does not match its commitment", a.id)
98+
}
99+
p, err := trust.ParsePayload(payload)
100+
if err != nil {
101+
// A malformed payload cannot happen for an entry a compliant
102+
// node accepted (checkAttestationPayload runs at Submit time),
103+
// but this source must not assume every node is compliant.
104+
continue
105+
}
106+
out = append(out, trust.Attestation{
107+
Entry: *a.e,
108+
Payload: p,
109+
Revoked: revoked[revocation{target: a.id, issuer: a.e.Issuer}],
110+
})
111+
}
112+
return out, nil
113+
}

0 commit comments

Comments
 (0)