// Package approvalfact provides data-only Approval claim correspondence. // It neither authenticates a provider nor decides permission or consumes objects. package approvalfact import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "math" "math/big" "sort" "strconv" "strings" "unicode/utf8" ) const MaxJSONBytes = 1 << 20 var errJSON = errors.New("invalid Approval JSON") // DecodeJSON retains numeric lexical types, refuses duplicates and invalid // Unicode, and bounds work. A Go map previously decoded into float64 cannot // recover the producer's integer-versus-floating identity; pass source bytes. func DecodeJSON(raw []byte) (any, error) { if len(raw) > MaxJSONBytes || !utf8.Valid(raw) || !validSurrogates(raw) { return nil, errJSON } d := json.NewDecoder(bytes.NewReader(raw)) d.UseNumber() var read func(int) (any, error) read = func(depth int) (any, error) { if depth > 128 { return nil, errJSON } t, e := d.Token() if e != nil { return nil, errJSON } switch x := t.(type) { case json.Delim: if x == '{' { m := map[string]any{} for d.More() { k, e := d.Token() if e != nil { return nil, errJSON } s, ok := k.(string) if !ok { return nil, errJSON } if _, ok = m[s]; ok { return nil, errJSON } v, e := read(depth + 1) if e != nil { return nil, e } m[s] = v } end, e := d.Token() if e != nil || end != json.Delim('}') { return nil, errJSON } return m, nil } if x == '[' { a := []any{} for d.More() { v, e := read(depth + 1) if e != nil { return nil, e } a = append(a, v) } end, e := d.Token() if e != nil || end != json.Delim(']') { return nil, errJSON } return a, nil } return nil, errJSON case json.Number: if _, e := pythonNumber(x); e != nil { return nil, e } return x, nil case string, bool, nil: return x, nil default: return nil, errJSON } } v, e := read(0) if e != nil { return nil, e } if _, e = d.Token(); e != io.EOF { return nil, errJSON } return v, nil } // encoding/json replaces lone UTF-16 surrogates, whereas the Python producer // cannot encode them as UTF-8. Reject before the decoder can erase that fact. func validSurrogates(raw []byte) bool { in := false for i := 0; i < len(raw); i++ { if raw[i] == '"' { in = !in continue } if !in || raw[i] != '\\' { continue } i++ if i >= len(raw) { return false } if raw[i] != 'u' { continue } if i+4 >= len(raw) { return false } n, e := strconv.ParseUint(string(raw[i+1:i+5]), 16, 16) if e != nil { return false } i += 4 if n >= 0xdc00 && n <= 0xdfff { return false } if n < 0xd800 || n > 0xdbff { continue } if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' { return false } low, e := strconv.ParseUint(string(raw[i+3:i+7]), 16, 16) if e != nil || low < 0xdc00 || low > 0xdfff { return false } i += 6 } return !in } func pythonNumber(n json.Number) (string, error) { s := string(n) if !strings.ContainsAny(s, ".eE") { i, ok := new(big.Int).SetString(s, 10) if !ok { return "", errJSON } return i.String(), nil } f, e := strconv.ParseFloat(s, 64) if e != nil || math.IsInf(f, 0) || math.IsNaN(f) { return "", errJSON } // Python repr uses shortest round-trip digits, fixed form for exponents // -4..15, a mandatory .0 for integral floats, and two exponent digits. sci := strconv.FormatFloat(f, 'e', -1, 64) at := strings.IndexByte(sci, 'e') exp, _ := strconv.Atoi(sci[at+1:]) if exp >= -4 && exp < 16 { s = strconv.FormatFloat(f, 'f', -1, 64) if !strings.Contains(s, ".") { s += ".0" } return s, nil } sign := "+" if exp < 0 { sign = "-" exp = -exp } digits := strconv.Itoa(exp) if len(digits) < 2 { digits = "0" + digits } return sci[:at] + "e" + sign + digits, nil } func quote(s string) string { var b strings.Builder b.WriteByte('"') for _, r := range s { switch r { case '"', '\\': b.WriteByte('\\') b.WriteRune(r) case '\b': b.WriteString(`\b`) case '\f': b.WriteString(`\f`) case '\n': b.WriteString(`\n`) case '\r': b.WriteString(`\r`) case '\t': b.WriteString(`\t`) default: if r < 32 { b.WriteString(`\u00`) const h = "0123456789abcdef" b.WriteByte(h[byte(r)>>4]) b.WriteByte(h[byte(r)&15]) } else { b.WriteRune(r) } } } b.WriteByte('"') return b.String() } func canonical(v any) (string, error) { switch x := v.(type) { case nil: return "null", nil case bool: if x { return "true", nil } return "false", nil case string: return quote(x), nil case json.Number: return pythonNumber(x) case []any: a := make([]string, len(x)) for i, v := range x { s, e := canonical(v) if e != nil { return "", e } a[i] = s } return "[" + strings.Join(a, ",") + "]", nil case map[string]any: keys := make([]string, 0, len(x)) for k := range x { keys = append(keys, k) } sort.Strings(keys) a := make([]string, 0, len(keys)) for _, k := range keys { s, e := canonical(x[k]) if e != nil { return "", e } a = append(a, quote(k)+":"+s) } return "{" + strings.Join(a, ",") + "}", nil default: return "", errJSON } } // CanonicalBinding reproduces approval_engine.binding.binding_digest over the // five native act fields. Extra binding metadata is excluded by that contract. func CanonicalBinding(raw []byte) ([]byte, string, error) { v, e := DecodeJSON(raw) if e != nil { return nil, "", e } m, ok := v.(map[string]any) if !ok { return nil, "", errJSON } act := map[string]any{} for _, k := range []string{"action", "actor", "principal", "purpose"} { s, ok := m[k].(string) if !ok || s == "" { return nil, "", errJSON } act[k] = s } target, ok := m["target"].(map[string]any) if !ok { return nil, "", errJSON } act["target"] = target s, e := canonical(act) if e != nil { return nil, "", e } sum := sha256.Sum256([]byte(s)) return []byte(s), "sha256:" + hex.EncodeToString(sum[:]), nil }