Skip to content

Commit da91570

Browse files
authored
axt-verify v0.2.0 (#5)
Squash merge of 1 commit
1 parent 5f85fa6 commit da91570

9 files changed

Lines changed: 873 additions & 73 deletions

File tree

‎README.md‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ key, so pass such a file with `--from-trusted`, which takes its tree size and
9595
root hash as your own record without checking the signature. The origin is
9696
still enforced.
9797

98+
**Leaf field sets also ship by release.** The log commits each event under a
99+
numbered set of fields (see *How verification works*), and a release can
100+
rebuild only the sets that existed when it was cut. This release rebuilds v1
101+
and v2; the log may begin writing v2 at any time after this release. A release
102+
older than the log's newest set reports every event written under it as "not
103+
what the log committed", whether or not the event carries a newer field. If
104+
every event after some date fails that way, install the latest release and run
105+
again until a pass completes without reporting truncation; a failure that
106+
survives that is a security finding.
107+
98108
## Commands
99109

100110
| Command | What it does |
@@ -131,7 +141,7 @@ Schedule it — hourly is reasonable — and alert on the exit status:
131141
| Exit | Meaning | Action |
132142
|---|---|---|
133143
| **0** | Nothing failed. Events served with no leaf, and — under `run` — events still awaiting a checkpoint, are reported rather than verified — see *Event outcomes*. | — |
134-
| **1** | **Verification failed**: a bad signature, wrong origin, a log that shrank or forked, an invalid proof, or an event whose served content is not what the log committed. | Treat as a security finding. Keep the state file and the output; contact Anthropic. |
144+
| **1** | **Verification failed**: a bad signature, wrong origin, a log that shrank or forked, an invalid proof, or an event whose served content is not what the log committed. | If every event after some date fails as "not what the log committed", first rule out an old release (*Leaf field sets also ship by release*). If that is not the cause, and for any other failure, treat it as a security finding. Keep the state file and the output; contact Anthropic. |
135145
| **2** | Usage error. | Fix the invocation. |
136146
| **3** | Could not complete: network failure, API errors, rate limiting after retries, or an `events` file holding an index the log has published no checkpoint for, or no proof for one it published while the check waited. | Rerun; page only if it persists. |
137147

@@ -248,15 +258,21 @@ on that log staying reachable.
248258
(`GET …/transparency_log/consistency?from=N`). Checkpoints that arrive
249259
embedded in proof responses mid-run are linked into the same history the same
250260
way before anything is verified against them.
251-
- **Inclusion.** Each served event is projected onto leaf field set v1 —
252-
eleven keys, RFC 8785 canonical JSON, prefixed with the version byte `0x01` —
253-
exactly as the Compliance API reference's *Leaf canonicalization* section
254-
specifies; `leaf/` is that specification in code and is tested against the
255-
log's own golden vectors. The leaf hash and the audit path from
261+
- **Inclusion.** Each served event is projected onto a leaf field set, as
262+
RFC 8785 canonical JSON behind a version byte. Field set v1 (`0x01`) is
263+
eleven keys, exactly as the Compliance API reference's *Leaf
264+
canonicalization* section specifies; `leaf/`'s v1 renderer is that
265+
specification in code and is tested against the log's own golden vectors.
266+
Field set v2 (`0x02`) is the same eleven keys plus `user_profile_id` when
267+
the event has one. An event that carries a `user_profile_id` is checked as
268+
the `0x02` leaf with that key and as nothing else, so an id added to, removed
269+
from or altered in a served event fails. Any other event is checked as a `0x02` leaf first and
270+
then as a `0x01` leaf, and a match with either verifies it: both commit the
271+
same eleven values. The leaf hash and the audit path from
256272
`GET …/transparency_log/inclusion?leaf_index=N` must reproduce the signed
257-
root. An event type outside v1's list (`anthropic_access`, `cmek_preserve`)
258-
is refused: new types ship under a new version byte and need a newer
259-
`axt-verify`.
273+
root. An event type outside the two versions' shared list
274+
(`anthropic_access`, `cmek_preserve`) is refused: new types ship under a
275+
new version byte and need a newer `axt-verify`.
260276
- **Nothing from the API is trusted** until it verifies against the key this
261277
release carries: not the checkpoint, not the proofs, not the leaf index.
262278

@@ -282,6 +298,9 @@ on that log staying reachable.
282298
- Deleting the state file re-baselines the next run: it starts from whatever
283299
checkpoint the log serves (exit 0) and can detect no rollback that run. Keep
284300
an archived note (`--save`, handed back with `--from`) as the durable anchor.
301+
- A state file a newer release has written is not for an older one: the older
302+
release reads the newer leaf hashes as changed content and fails every event
303+
that was awaiting a checkpoint.
285304
- The Compliance Access Key is read from `$ANTHROPIC_COMPLIANCE_ACCESS_KEY` and
286305
grants read access to all of your organization's compliance activities. A
287306
process running as the same user can read the cron's environment; scope that

‎cmd/axt-verify/main_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func setupAt(t *testing.T, logOrigin string) *fixture {
5959
if err != nil {
6060
t.Fatal(err)
6161
}
62-
l.Append(ev.Entry)
62+
l.Append(ev.Legacy) // the 0x01 entry: the only leaf any log writes when this release ships
6363
srv.Events = append(srv.Events, raw)
6464
f.events = append(f.events, raw)
6565
}

‎leaf/leaf.go‎

Lines changed: 154 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@
44
// Package leaf rebuilds Access Transparency transparency-log leaf entries
55
// from events exactly as the Compliance API activity feed serves them.
66
//
7-
// A leaf entry is SchemaVersion followed by RFC 8785 (JCS) canonical JSON
8-
// over field set v1 — eleven keys projected from the served event. Its
9-
// RFC 6962 leaf hash is SHA-256(0x00 || entry). The projection rules are
10-
// the public transparency-log specification's § Leaf canonicalization:
11-
// values are copied byte for byte, a key the served event omits enters as
12-
// null, keys outside the set are ignored, and actor.email_address and
13-
// resource_details.parent are always null under schema version 0x01.
7+
// A leaf entry is a schema version byte followed by RFC 8785 (JCS)
8+
// canonical JSON over that version's field set, projected from the served
9+
// event. Version 0x01 is field set v1, eleven keys. Version 0x02 is field
10+
// set v2: the same eleven plus user_profile_id, present only when the event
11+
// has one. This package rebuilds the 0x02 entry for every event and, for an
12+
// event with no user_profile_id, the 0x01 entry as well; such an event
13+
// verifies against either, because both commit the same eleven values. An
14+
// event that carries a user_profile_id has the 0x02 entry alone: no 0x01
15+
// entry commits one. An entry's RFC 6962 leaf hash is
16+
// SHA-256(0x00 || entry). Field set v1's projection rules are the public
17+
// transparency-log specification's § Leaf canonicalization: values are
18+
// copied byte for byte, an omitted key enters as null, keys outside the set
19+
// are ignored, and actor.email_address and resource_details.parent are
20+
// always null. Field set v2 keeps them; its one added key is left out, never
21+
// null.
1422
package leaf
1523

1624
import (
@@ -31,15 +39,22 @@ import (
3139
// SchemaVersion is the leaf-entry version byte for field set v1.
3240
const SchemaVersion byte = 0x01
3341

42+
// SchemaVersionV2 is the leaf-entry version byte for field set v2: v1's
43+
// eleven keys plus user_profile_id when the event has one.
44+
const SchemaVersionV2 byte = 0x02
45+
46+
// UserProfileIDKey is the served key field set v2 adds.
47+
const UserProfileIDKey = "user_profile_id"
48+
3449
// IndexKey is the served key carrying an event's position in its
3550
// organization's log; null when the event has no leaf.
3651
const IndexKey = "transparency_log_leaf_index"
3752

3853
var types = []string{"anthropic_access", "cmek_preserve"}
3954

40-
// Types returns schema version 0x01's closed event-type list. An event of
41-
// any other type cannot be a 0x01 leaf: new types ship under a new version
42-
// byte and need a newer verifier.
55+
// Types returns the closed event-type list schema versions 0x01 and 0x02
56+
// share. An event of any other type cannot be a leaf of either: new types
57+
// ship under a new version byte and need a newer verifier.
4358
func Types() []string { return slices.Clone(types) }
4459

4560
// TypeOf reads an activity's "type" through the same strict decode Parse
@@ -111,7 +126,7 @@ func foldRune(r rune) rune {
111126
}
112127

113128
// ErrUnknownType means the served event's type is outside Types.
114-
var ErrUnknownType = errors.New("event type is outside leaf schema version 0x01's type list; a newer axt-verify is required")
129+
var ErrUnknownType = errors.New("event type is not one this axt-verify release knows; a newer axt-verify is required")
115130

116131
// Event is what a verifier needs from one served event.
117132
type Event struct {
@@ -122,15 +137,46 @@ type Event struct {
122137
// Index is the event's leaf index; nil when the event has no leaf
123138
// (recorded while the organization had no active log).
124139
Index *uint64
125-
// Entry is the leaf entry: SchemaVersion || canonical JSON.
140+
// Entry is the event's 0x02 entry: SchemaVersionV2 || canonical JSON
141+
// over field set v2.
126142
Entry []byte
143+
// Legacy is the same event's 0x01 entry. nil when the event carries a
144+
// user_profile_id: no 0x01 entry commits one.
145+
Legacy []byte
146+
// withheld is the 0x01 entry over the eleven keys of an event that
147+
// carries a user_profile_id. Only RecordedFrom reads it, and RecordedFrom
148+
// supplies no hash to an inclusion check.
149+
withheld []byte
127150
}
128151

129-
// Hash returns the RFC 6962 leaf hash of the entry.
152+
// Hash returns the RFC 6962 leaf hash of Entry.
130153
func (e Event) Hash() []byte { return rfc6962.DefaultHasher.HashLeaf(e.Entry) }
131154

155+
// Hashes returns the leaf hashes the event can have in the log, in the
156+
// order to try them: Entry's, then Legacy's when there is one. Nothing
157+
// served says which version a leaf is, so the served event alone decides.
158+
func (e Event) Hashes() [][]byte {
159+
hashes := [][]byte{e.Hash()}
160+
if e.Legacy != nil {
161+
hashes = append(hashes, rfc6962.DefaultHasher.HashLeaf(e.Legacy))
162+
}
163+
return hashes
164+
}
165+
166+
// RecordedFrom reports whether any release of this tool, including one that
167+
// knew only 0x01, would have recorded the stored leaf hash from this serving.
168+
// It compares servings only: true says nothing about what the log holds.
169+
func (e Event) RecordedFrom(stored []byte) bool {
170+
hashes := e.Hashes()
171+
if e.withheld != nil {
172+
hashes = append(hashes, rfc6962.DefaultHasher.HashLeaf(e.withheld))
173+
}
174+
return slices.ContainsFunc(hashes, func(h []byte) bool { return bytes.Equal(h, stored) })
175+
}
176+
132177
// Parse projects one served event — a JSON object as it appears in the
133-
// activity feed's data array or a SIEM export of it — onto field set v1.
178+
// activity feed's data array or a SIEM export of it — onto field set v2,
179+
// and onto field set v1 as well when it carries no user_profile_id.
134180
// Served bytes that are ambiguous about a projected value are refused, not
135181
// resolved: a repeated member name, a name differing from a projected one
136182
// only in case, or a string the JSON decoder would repair. Resolving one
@@ -186,15 +232,30 @@ func Parse(served []byte) (Event, error) {
186232
return Event{}, fmt.Errorf("resource_details.%w", err)
187233
}
188234
}
235+
if r.userProfileID, err = optString(top, UserProfileIDKey); err != nil {
236+
return Event{}, err
237+
}
189238
idx, err := index(top)
190239
if err != nil {
191240
return Event{}, err
192241
}
193-
j := r.render()
194-
entry := make([]byte, 0, 1+len(j))
195-
entry = append(entry, SchemaVersion)
196-
entry = append(entry, j...)
197-
return Event{ID: deref(r.id), OrganizationUUID: deref(r.organizationUUID), Index: idx, Entry: entry}, nil
242+
return r.event(idx), nil
243+
}
244+
245+
// event frames the record's two entries.
246+
func (r record) event(idx *uint64) Event {
247+
ev := Event{ID: deref(r.id), OrganizationUUID: deref(r.organizationUUID), Index: idx}
248+
ev.Entry = frame(SchemaVersionV2, r.renderV2())
249+
if legacy := frame(SchemaVersion, r.render()); r.userProfileID == nil {
250+
ev.Legacy = legacy
251+
} else {
252+
ev.withheld = legacy
253+
}
254+
return ev
255+
}
256+
257+
func frame(version byte, j []byte) []byte {
258+
return append([]byte{version}, j...)
198259
}
199260

200261
type record struct {
@@ -204,6 +265,9 @@ type record struct {
204265
reasonCode *string
205266
actor *actorRecord
206267
resource *resourceRecord
268+
// userProfileID is nil for an event with none; a null served value
269+
// reads as absent.
270+
userProfileID *string
207271
}
208272

209273
type actorRecord struct{ typ *string }
@@ -275,6 +339,76 @@ func (r record) render() []byte {
275339
return b.Bytes()
276340
}
277341

342+
// renderV2 emits field set v2: v1's eleven keys plus, for an event that has
343+
// one, user_profile_id in its code-point position between type and
344+
// workspace_id. There is no null user_profile_id. A separate renderer on
345+
// purpose: a released version's renderer is never edited.
346+
func (r record) renderV2() []byte {
347+
var b bytes.Buffer
348+
b.WriteByte('{')
349+
350+
writeKey(&b, "accessed_at", false)
351+
writeOpt(&b, r.accessedAt)
352+
353+
writeKey(&b, "accessor_department", true)
354+
writeOpt(&b, r.accessorDepartment)
355+
356+
// email_address is fixed null under 0x02 whatever the served record
357+
// shows: no entry commits an email address.
358+
writeKey(&b, "actor", true)
359+
if r.actor != nil {
360+
b.WriteString(`{"email_address":null,"type":`)
361+
writeOpt(&b, r.actor.typ)
362+
b.WriteByte('}')
363+
} else {
364+
b.WriteString("null")
365+
}
366+
367+
writeKey(&b, "created_at", true)
368+
writeOpt(&b, r.createdAt)
369+
370+
writeKey(&b, "id", true)
371+
writeOpt(&b, r.id)
372+
373+
writeKey(&b, "organization_id", true)
374+
writeOpt(&b, r.organizationID)
375+
376+
writeKey(&b, "organization_uuid", true)
377+
writeOpt(&b, r.organizationUUID)
378+
379+
writeKey(&b, "reason_code", true)
380+
writeOpt(&b, r.reasonCode)
381+
382+
writeKey(&b, "resource_details", true)
383+
if r.resource != nil {
384+
b.WriteByte('{')
385+
writeKey(&b, "id", false)
386+
writeOpt(&b, r.resource.id)
387+
// parent is reserved and always null under 0x02.
388+
writeKey(&b, "parent", true)
389+
b.WriteString("null")
390+
writeKey(&b, "type", true)
391+
writeOpt(&b, r.resource.typ)
392+
b.WriteByte('}')
393+
} else {
394+
b.WriteString("null")
395+
}
396+
397+
writeKey(&b, "type", true)
398+
writeOpt(&b, r.typ)
399+
400+
if r.userProfileID != nil {
401+
writeKey(&b, UserProfileIDKey, true)
402+
writeString(&b, *r.userProfileID)
403+
}
404+
405+
writeKey(&b, "workspace_id", true)
406+
writeOpt(&b, r.workspaceID)
407+
408+
b.WriteByte('}')
409+
return b.Bytes()
410+
}
411+
278412
func writeKey(b *bytes.Buffer, key string, comma bool) {
279413
if comma {
280414
b.WriteByte(',')
@@ -331,7 +465,7 @@ var (
331465
topKeys = []string{
332466
"accessed_at", "accessor_department", "actor", "created_at", "id",
333467
"organization_id", "organization_uuid", "reason_code", "resource_details",
334-
"type", "workspace_id", IndexKey,
468+
"type", "workspace_id", IndexKey, UserProfileIDKey,
335469
}
336470
actorKeys = []string{"email_address", "type"}
337471
resourceKeys = []string{"id", "parent", "type"}

0 commit comments

Comments
 (0)