Skip to content

Commit 8be5355

Browse files
fix(metadata): honour status report order and effective dates (#736)
Status reports are a history rather than a set. Desired statuses are now matched against the current status only, so an authenticator which is presently revoked no longer satisfies a certification level it held previously. Undesired statuses remain matched against every report in effect as a compromise is not retracted by a later report. The effectiveDate and sunsetDate values are now honoured, excluding reports which have not come into effect and those which have expired. BREAKING CHANGE: the desired statuses given to ValidateStatusReports are now a set of acceptable current statuses rather than a set which must all appear somewhere in the report history.
1 parent 309ea69 commit 8be5355

4 files changed

Lines changed: 392 additions & 59 deletions

File tree

metadata/providers/memory/provider_test.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,19 @@ func TestProvider_ValidateStatusReports(t *testing.T) {
207207
WithStatusUndesired(nil),
208208
},
209209
reports: []metadata.StatusReport{{Status: metadata.NotFidoCertified}},
210-
err: "The following desired status reports were absent: FIDO_CERTIFIED",
210+
err: "the current status report 'NOT_FIDO_CERTIFIED' was not one of the desired statuses: FIDO_CERTIFIED",
211+
},
212+
{
213+
// The desired status must be the current one rather than merely present in the history.
214+
name: "ShouldFailWithDesiredStatusSuperseded",
215+
opts: []Option{
216+
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
217+
WithValidateStatus(true),
218+
WithStatusDesired([]metadata.AuthenticatorStatus{metadata.FidoCertifiedL2}),
219+
WithStatusUndesired(nil),
220+
},
221+
reports: []metadata.StatusReport{{Status: metadata.FidoCertifiedL2}, {Status: metadata.Revoked}},
222+
err: "the current status report 'REVOKED' was not one of the desired statuses: FIDO_CERTIFIED_L2",
211223
},
212224
}
213225

metadata/status.go

Lines changed: 139 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,62 +3,175 @@ package metadata
33
import (
44
"fmt"
55
"strings"
6+
"time"
67
)
78

8-
// ValidateStatusReports checks a list of [StatusReport] structs against a list of desired and undesired [AuthenticatorStatus]
9-
// values. If the reports contain all of the desired and none of the undesired status reports then no error is returned
10-
// otherwise an error describing the issue is returned.
9+
// InEffectAt returns true if this [StatusReport] is in effect at the given time.
1110
//
12-
//nolint:gocyclo
11+
// A report which has an effective date in the future has not come into effect yet, and a report which has reached its
12+
// sunset date has expired. An absent effective date means the report is in effect while present, and an absent sunset
13+
// date means it has no scheduled expiry.
14+
//
15+
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-stat-rep
16+
func (r StatusReport) InEffectAt(at time.Time) bool {
17+
if r.EffectiveDate != nil && r.EffectiveDate.After(at) {
18+
return false
19+
}
20+
21+
if r.SunsetDate != nil && !r.SunsetDate.After(at) {
22+
return false
23+
}
24+
25+
return true
26+
}
27+
28+
// EffectiveStatusReports returns the subset of the given [StatusReport] values which are in effect at the given time,
29+
// preserving their original order. See [StatusReport.InEffectAt] for the rules applied.
30+
func EffectiveStatusReports(reports []StatusReport, at time.Time) (effective []StatusReport) {
31+
for _, report := range reports {
32+
if report.InEffectAt(at) {
33+
effective = append(effective, report)
34+
}
35+
}
36+
37+
return effective
38+
}
39+
40+
// CurrentStatusReportAt returns the [StatusReport] which reflects the current status of the authenticator at the given
41+
// time, or nil if no report is in effect. See [currentStatusReport] for how the current report is selected.
42+
func CurrentStatusReportAt(reports []StatusReport, at time.Time) (current *StatusReport) {
43+
return currentStatusReport(EffectiveStatusReports(reports, at))
44+
}
45+
46+
// currentStatusReport returns the report reflecting the current status from a set of reports already known to be in
47+
// effect, or nil if there are none.
48+
//
49+
// MDS3 requires that the latest entry reflects the current status, so the last element wins by default. Where both
50+
// candidates carry an effective date that date is preferred over the document order, which keeps the selection correct
51+
// for a blob whose reports are not listed oldest first. Ties resolve to the later element.
52+
func currentStatusReport(effective []StatusReport) (current *StatusReport) {
53+
for i := range effective {
54+
report := &effective[i]
55+
56+
if current == nil || current.EffectiveDate == nil || report.EffectiveDate == nil {
57+
current = report
58+
59+
continue
60+
}
61+
62+
if !report.EffectiveDate.Before(*current.EffectiveDate) {
63+
current = report
64+
}
65+
}
66+
67+
return current
68+
}
69+
70+
// ValidateStatusReports checks a list of [StatusReport] structs against a list of desired and undesired
71+
// [AuthenticatorStatus] values as at the current time. See [ValidateStatusReportsAt] for the validation performed.
1372
func ValidateStatusReports(reports []StatusReport, desired, undesired []AuthenticatorStatus) (err error) {
14-
if len(desired) == 0 && (len(undesired) == 0 || len(reports) == 0) {
73+
return ValidateStatusReportsAt(reports, desired, undesired, time.Now())
74+
}
75+
76+
// ValidateStatusReportsAt checks a list of [StatusReport] structs against a list of desired and undesired
77+
// [AuthenticatorStatus] values as at the given time. If the reports satisfy the desired statuses and contain none of
78+
// the undesired statuses then no error is returned, otherwise an error describing the issue is returned.
79+
//
80+
// Reports which are not in effect at the given time are excluded from consideration entirely, i.e. those with an
81+
// effective date in the future and those which have reached their sunset date.
82+
//
83+
// The desired statuses are matched against the current status only, and are treated as a set of acceptable statuses of
84+
// which one must match. MDS3 requires that the latest report reflects the current status, so an authenticator formerly
85+
// certified at some level but since revoked does not satisfy a desired status naming that certification level.
86+
//
87+
// The undesired statuses are matched against every report in effect rather than the current one alone. Statuses such as
88+
// [AttestationKeyCompromise] and [UserKeyPhysicalCompromise] describe a weakness discovered in the authenticator model
89+
// which a later report does not retract; the sunset date honored above is the mechanism by which such a report stops
90+
// applying.
91+
//
92+
// Note that a compromise reported against a specific attestation certificate, i.e. where [StatusReport.Certificate] or
93+
// [StatusReport.BatchCertificate] is set, applies only to that batch of authenticators. This function has no visibility
94+
// of the attestation statement being validated, so it conservatively treats such a report as applying to every
95+
// authenticator of the model.
96+
func ValidateStatusReportsAt(reports []StatusReport, desired, undesired []AuthenticatorStatus, at time.Time) (err error) {
97+
if len(desired) == 0 && len(undesired) == 0 {
1598
return nil
1699
}
17100

18-
var present, absent []string
101+
effective := EffectiveStatusReports(reports, at)
102+
103+
var present []string
19104

20105
if len(undesired) != 0 {
21-
for _, report := range reports {
22-
for _, status := range undesired {
23-
if report.Status == status {
24-
present = append(present, string(status))
106+
seen := make(map[AuthenticatorStatus]struct{}, len(undesired))
25107

26-
continue
27-
}
108+
for _, report := range effective {
109+
if _, ok := seen[report.Status]; ok {
110+
continue
111+
}
112+
113+
if hasStatus(report.Status, undesired) {
114+
seen[report.Status] = struct{}{}
115+
116+
present = append(present, string(report.Status))
28117
}
29118
}
30119
}
31120

121+
var (
122+
current *StatusReport
123+
unsatisfied bool
124+
)
125+
32126
if len(desired) != 0 {
33-
desired:
34-
for _, status := range desired {
35-
for _, report := range reports {
36-
if report.Status == status {
37-
continue desired
38-
}
39-
}
127+
current = currentStatusReport(effective)
40128

41-
absent = append(absent, string(status))
42-
}
129+
unsatisfied = current == nil || !hasStatus(current.Status, desired)
43130
}
44131

45132
switch {
46-
case len(present) == 0 && len(absent) == 0:
133+
case len(present) == 0 && !unsatisfied:
47134
return nil
48-
case len(present) != 0 && len(absent) == 0:
135+
case len(present) != 0 && !unsatisfied:
49136
return &Error{
50137
Type: "invalid_status",
51138
Details: fmt.Sprintf("The following undesired status reports were present: %s", strings.Join(present, ", ")),
52139
}
53-
case len(present) == 0 && len(absent) != 0:
140+
case len(present) == 0:
54141
return &Error{
55142
Type: "invalid_status",
56-
Details: fmt.Sprintf("The following desired status reports were absent: %s", strings.Join(absent, ", ")),
143+
Details: describeDesiredUnsatisfied(current, desired),
57144
}
58145
default:
59146
return &Error{
60147
Type: "invalid_status",
61-
Details: fmt.Sprintf("The following undesired status reports were present: %s; the following desired status reports were absent: %s", strings.Join(present, ", "), strings.Join(absent, ", ")),
148+
Details: fmt.Sprintf("The following undesired status reports were present: %s; %s", strings.Join(present, ", "), describeDesiredUnsatisfied(current, desired)),
62149
}
63150
}
64151
}
152+
153+
// hasStatus returns true if the given status is a member of the given values.
154+
func hasStatus(status AuthenticatorStatus, values []AuthenticatorStatus) bool {
155+
for _, value := range values {
156+
if value == status {
157+
return true
158+
}
159+
}
160+
161+
return false
162+
}
163+
164+
// describeDesiredUnsatisfied renders the reason the desired statuses were not satisfied by the current status report.
165+
func describeDesiredUnsatisfied(current *StatusReport, desired []AuthenticatorStatus) string {
166+
statuses := make([]string, len(desired))
167+
168+
for i, status := range desired {
169+
statuses[i] = string(status)
170+
}
171+
172+
if current == nil {
173+
return fmt.Sprintf("no status report was in effect so none of the desired statuses could be satisfied: %s", strings.Join(statuses, ", "))
174+
}
175+
176+
return fmt.Sprintf("the current status report '%s' was not one of the desired statuses: %s", current.Status, strings.Join(statuses, ", "))
177+
}

0 commit comments

Comments
 (0)