diff --git a/client/go-cli/internal/identity/portable.go b/client/go-cli/internal/identity/portable.go new file mode 100644 index 0000000..edd229e --- /dev/null +++ b/client/go-cli/internal/identity/portable.go @@ -0,0 +1,124 @@ +package identity + +import ( + "bytes" + "crypto/ecdsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "path/filepath" + "time" +) + +type portableSource struct { + OwnerID string `json:"owner_id"` + AgentID string `json:"agent_id"` + Fingerprint string `json:"issuer_sha256"` + OwnerDER []byte `json:"owner_der"` + Certificate []byte `json:"certificate_der"` +} + +// PortableIdentity is local enrolled evidence, not target-server authorization. +// Its private key can only be reused through NewProvisionalFromIdentity. +type PortableIdentity struct { + source portableSource + key *ecdsa.PrivateKey + serverURL string +} + +func (PortableIdentity) String() string { return "" } +func (p PortableIdentity) GoString() string { return p.String() } +func (PortableIdentity) MarshalJSON() ([]byte, error) { return nil, errProvisional } + +func decodePortableSource(raw []byte, key *ecdsa.PrivateKey, current *time.Time) (*portableSource, error) { + if bytes.Equal(raw, []byte("null")) { + return nil, nil + } + var s portableSource + if len(raw) == 0 || json.Unmarshal(raw, &s) != nil { + return nil, errProvisional + } + canonical, err := json.Marshal(s) + if err != nil || !bytes.Equal(canonical, raw) || !uuid4.MatchString(s.OwnerID) || !uuid4.MatchString(s.AgentID) || s.OwnerID == s.AgentID || len(s.OwnerDER) > MaxCertificateBytes || len(s.Certificate) > MaxCertificateBytes { + return nil, errProvisional + } + hash := sha256.Sum256(s.OwnerDER) + if s.Fingerprint != hex.EncodeToString(hash[:]) { + return nil, errProvisional + } + cert, err := x509.ParseCertificate(s.Certificate) + if err != nil { + return nil, errProvisional + } + at := cert.NotBefore + if current != nil { + at = *current + } + cert, err = VerifyCredential(s.Certificate, Identity{Role: Portable, SubjectID: s.AgentID}, &Issuer{DER: s.OwnerDER, ID: s.OwnerID}, at) + if err != nil || key == nil || !samePublicKey(cert.PublicKey, key.Public()) { + return nil, errProvisional + } + return &s, nil +} + +// LoadPortableIdentity reads only enrolled/ under the supplied state root. +// Hold the existing enrollment owner across this read and the new enrollment. +// No server connection or source access-certificate lifetime is required. +func LoadPortableIdentity(stateRoot, selector string, now time.Time) (*PortableIdentity, error) { + material, raw, server, err := loadStoredEnrollment(filepath.Join(stateRoot, "enrolled"), selector, 1) + if err != nil { + return nil, err + } + prepared, err := verifyPreparedPurpose(raw, material, server, now, true, true) + if err != nil { + return nil, err + } + fields := raw[len(preparedTag)+144+22+32+32+8:] + var parts [4][]byte + for i := range parts { + parts[i], err = preparedField(&fields) + if err != nil { + return nil, err + } + } + hash := sha256.Sum256(parts[2]) + s := portableSource{prepared.OwnerID, prepared.AgentID, hex.EncodeToString(hash[:]), parts[2], parts[3]} + if material.record.Version == 2 && !bytes.Equal(material.record.Source, []byte("null")) { + encoded, _ := json.Marshal(s) + if !bytes.Equal(encoded, material.record.Source) { + return nil, errProvisional + } + } + return &PortableIdentity{s, material.portable, material.record.ServerURL}, nil +} + +// NewProvisionalFromIdentity makes v2 material; nil selects a fresh identity. +func NewProvisionalFromIdentity(serverURL, name string, now time.Time, source *PortableIdentity) (*Provisional, PairingSecret, error) { + if source != nil { + raw, _ := json.Marshal(source.source) + if _, err := decodePortableSource(raw, source.key, &now); err != nil || source.serverURL == serverURL { + return nil, PairingSecret{}, errProvisional + } + } + p, secret, err := NewProvisional(serverURL, name, now) + if err != nil { + return nil, secret, err + } + p.record.Version = 2 + p.record.Source = json.RawMessage("null") + if source != nil { + p.portable = source.key + der, err := x509.MarshalPKCS8PrivateKey(p.portable) + if err != nil { + return nil, PairingSecret{}, err + } + p.record.PortableKey = base64.StdEncoding.EncodeToString(der) + p.record.Source, err = json.Marshal(source.source) + if err != nil { + return nil, PairingSecret{}, err + } + } + return p, secret, nil +} diff --git a/client/go-cli/internal/identity/portable_test.go b/client/go-cli/internal/identity/portable_test.go new file mode 100644 index 0000000..5adf941 --- /dev/null +++ b/client/go-cli/internal/identity/portable_test.go @@ -0,0 +1,334 @@ +package identity + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func portableFixture(t *testing.T, portableAgo, accessAgo time.Duration) (*Provisional, []byte, time.Time) { + t.Helper() + now := time.Now().UTC().Truncate(time.Second) + p, _, err := NewProvisional("https://source.invalid", "source", now) + if err != nil { + t.Fatal(err) + } + ids := []string{"10000000-0000-4000-8000-000000000001", "20000000-0000-4000-8000-000000000002", "30000000-0000-4000-8000-000000000003", "40000000-0000-4000-8000-000000000004"} + issue := func(id Identity, key *ecdsa.PrivateKey, parent *x509.Certificate, parentKey *ecdsa.PrivateKey, at time.Time) []byte { + names, e := id.names() + if e != nil { + t.Fatal(e) + } + sanValue, _ := asn1.Marshal(names) + c := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: string(id.Role)}, NotBefore: at.Add(-time.Minute), NotAfter: at.Add(365 * 24 * time.Hour), BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtraExtensions: []pkix.Extension{{Id: san, Critical: true, Value: sanValue}}} + if id.ca() { + c.IsCA = true + c.MaxPathLenZero = true + c.KeyUsage = x509.KeyUsageCertSign + c.NotAfter = at.Add(10 * 365 * 24 * time.Hour) + parent = c + parentKey = key + } else { + purpose := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 2} + if id.Role == ServerTLS { + purpose[8] = 1 + } + value, _ := asn1.Marshal([]asn1.ObjectIdentifier{purpose}) + c.ExtraExtensions = append(c.ExtraExtensions, pkix.Extension{Id: eku, Critical: true, Value: value}) + } + der, e := x509.CreateCertificate(rand.Reader, c, parent, key.Public(), parentKey) + if e != nil { + t.Fatal(e) + } + return der + } + keys := make([]*ecdsa.PrivateKey, 3) + for i := range keys { + keys[i], err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + } + at := now.Add(-2 * 365 * 24 * time.Hour) + serverDER := issue(Identity{Role: ServerAuthority, SubjectID: ids[0]}, keys[0], nil, nil, at) + server, _ := x509.ParseCertificate(serverDER) + ownerDER := issue(Identity{Role: Owner, SubjectID: ids[1]}, keys[1], nil, nil, at) + owner, _ := x509.ParseCertificate(ownerDER) + tlsDER := issue(Identity{Role: ServerTLS, SubjectID: ids[0]}, keys[2], server, keys[0], now) + portableDER := issue(Identity{Role: Portable, SubjectID: ids[2]}, p.portable, owner, keys[1], now.Add(-portableAgo)) + accessDER := issue(Identity{Role: Access, SubjectID: ids[2], ServerID: ids[0], EnrollmentID: ids[3]}, p.access, server, keys[0], now.Add(-accessAgo)) + public, _ := p.Public() + ad, _ := json.Marshal(public) + hash := sha256.Sum256(ad) + raw := []byte(preparedTag + strings.Join(ids, "") + p.record.Selector) + raw = append(raw, hash[:]...) + raw = append(raw, []byte(strings.Repeat("a", 32))...) + raw = binary.BigEndian.AppendUint64(raw, uint64(now.Add(300*time.Second).Unix())) + recovery, _ := x509.MarshalPKIXPublicKey(keys[1].Public()) + origin, _ := x509.MarshalPKIXPublicKey(keys[2].Public()) + for _, field := range [][]byte{serverDER, tlsDER, ownerDER, portableDER, accessDER, public.RecipientKey, recovery, origin, []byte("[]")} { + raw = binary.BigEndian.AppendUint16(raw, uint16(len(field))) + raw = append(raw, field...) + } + return p, raw, now +} + +func savePortableFixture(t *testing.T, p *Provisional, raw []byte, root, domain string) []byte { + t.Helper() + private, err := p.EncodePrivate() + if err != nil { + t.Fatal(err) + } + bundle, err := json.Marshal(preparedPrivateRecord{privateWarning, 1, private, raw}) + if err != nil { + t.Fatal(err) + } + if err = savePrivateRecord(context.Background(), filepath.Join(root, domain), p.record.Selector, bundle, func(string) error { return nil }); err != nil { + t.Fatal(err) + } + return bundle +} + +func TestPortableSourcePurposeAndCustody(t *testing.T) { + for _, mode := range []string{"current", "expired-access", "expired-portable", "future-portable"} { + t.Run(mode, func(t *testing.T) { + portableAgo, accessAgo := time.Duration(0), time.Duration(0) + if mode == "expired-access" { + accessAgo = 366 * 24 * time.Hour + } + if mode == "expired-portable" { + portableAgo = 366 * 24 * time.Hour + } + if mode == "future-portable" { + portableAgo = -time.Hour + } + p, raw, now := portableFixture(t, portableAgo, accessAgo) + root := custodyTestDir(t) + before := savePortableFixture(t, p, raw, root, "enrolled") + path := filepath.Join(root, "enrolled", p.record.Selector+".json") + source, err := LoadPortableIdentity(root, p.record.Selector, now) + want := mode == "current" || mode == "expired-access" + if (err == nil) != want { + t.Fatal("source purpose", mode, err) + } + _, _, receiptErr := loadPrepared(filepath.Join(root, "enrolled"), p.record.Selector, now, true) + if (receiptErr == nil) != (mode == "current" || mode == "expired-portable") { + t.Fatal("receipt purpose", mode, receiptErr) + } + after, e := os.ReadFile(path) + if e != nil || !bytes.Equal(before, after) { + t.Fatal("source changed", e) + } + if !want { + return + } + if _, e := json.Marshal(source); e == nil { + t.Fatal("private source serialized") + } + if !strings.Contains(fmt.Sprintf("%#v", source), "redacted") { + t.Fatal("private source formatted") + } + target, _, e := NewProvisionalFromIdentity("https://target.invalid", "target", now, source) + if e != nil { + t.Fatal(e) + } + if !samePublicKey(target.portable.Public(), p.portable.Public()) || samePublicKey(target.access.Public(), p.access.Public()) || bytes.Equal(target.recipient.Bytes(), p.recipient.Bytes()) || target.record.Selector == p.record.Selector { + t.Fatal("reused unrelated source keys") + } + encoded, _ := target.EncodePrivate() + loaded, e := DecodeProvisional(encoded) + if e != nil { + t.Fatal(e) + } + a, _ := target.Public() + b, _ := loaded.Public() + aj, _ := json.Marshal(a) + bj, _ := json.Marshal(b) + if !bytes.Equal(aj, bj) || a.Version != 2 { + t.Fatal("v2 round trip") + } + if _, _, e = NewProvisionalFromIdentity(p.record.ServerURL, "same", now, source); e == nil { + t.Fatal("same target accepted") + } + preparedOnly := custodyTestDir(t) + savePortableFixture(t, p, raw, preparedOnly, "prepared") + if _, e = LoadPortableIdentity(preparedOnly, p.record.Selector, now); e == nil { + t.Fatal("prepared-only source accepted") + } + if err = os.Chmod(path, 0644); err == nil && runtime.GOOS != "windows" { + if _, e = LoadPortableIdentity(root, p.record.Selector, now); e == nil { + t.Fatal("unsafe file accepted") + } + os.Chmod(path, 0600) + } + }) + } +} + +func TestPortablePrivateClaimMutationAndLegacyEncoding(t *testing.T) { + p, raw, now := portableFixture(t, 0, 0) + root := custodyTestDir(t) + savePortableFixture(t, p, raw, root, "enrolled") + source, err := LoadPortableIdentity(root, p.record.Selector, now) + if err != nil { + t.Fatal(err) + } + target, _, err := NewProvisionalFromIdentity("https://target.invalid", "target", now, source) + if err != nil { + t.Fatal(err) + } + encoded, _ := target.EncodePrivate() + for _, field := range []string{"owner_id", "agent_id", "issuer_sha256", "owner_der", "certificate_der", "unknown", "Owner_ID"} { + t.Run(field, func(t *testing.T) { + var claim map[string]any + json.Unmarshal(target.record.Source, &claim) + claim[field] = "broken" + changed, _ := json.Marshal(claim) + r := target.record + r.Source = changed + data, _ := json.Marshal(r) + if _, e := DecodeProvisional(data); e == nil { + t.Fatal("changed claim accepted") + } + }) + } + for _, bad := range [][]byte{bytes.Replace(encoded, []byte(`"owner_id":`), []byte(`"owner_id":"duplicate","owner_id":`), 1), bytes.Replace(encoded, []byte(`"source_identity":`), []byte(`"source_identity":null,"source_identity":`), 1), append(encoded, 'x'), bytes.Repeat([]byte(" "), MaxPortableProvisionalBytes+1)} { + if _, e := DecodeProvisional(bad); e == nil { + t.Fatal("malformed v2 accepted") + } + } + legacy, _ := p.EncodePrivate() + public, _ := p.Public() + ad, _ := json.Marshal(public) + decoded, e := DecodeProvisional(legacy) + if e != nil { + t.Fatal(e) + } + again, _ := decoded.Public() + ad2, _ := json.Marshal(again) + if !bytes.Equal(ad, ad2) || bytes.Contains(ad, []byte("portable_identity")) || bytes.Contains(legacy, []byte("source_identity")) { + t.Fatal("v1 reconstruction changed") + } + if _, e := DecodeProvisional(append(legacy, bytes.Repeat([]byte(" "), MaxProvisionalBytes)...)); e == nil { + t.Fatal("v1 bound relaxed") + } + fresh, _, e := NewProvisionalFromIdentity("https://target.invalid", "fresh", now, nil) + if e != nil { + t.Fatal(e) + } + f, _ := fresh.Public() + fj, _ := json.Marshal(f) + if !bytes.Contains(fj, []byte(`"portable_identity":null`)) { + t.Fatal("v2 fresh missing null") + } +} + +func TestPythonPortableCodecPeer(t *testing.T) { + if os.Getenv("ARBITER_TEST_PORTABLE_CODEC") != "1" { + t.Skip("Python codec peer is not running") + } + p, raw, now := portableFixture(t, 0, 366*24*time.Hour) + root := custodyTestDir(t) + savePortableFixture(t, p, raw, root, "enrolled") + source, err := LoadPortableIdentity(root, p.record.Selector, now) + if err != nil { + t.Fatal(err) + } + for _, s := range []*PortableIdentity{nil, source} { + target, _, e := NewProvisionalFromIdentity("https://target.invalid", "target", now, s) + if e != nil { + t.Fatal(e) + } + private, _ := target.EncodePrivate() + target, e = DecodeProvisional(private) + if e != nil { + t.Fatal(e) + } + public, e := target.Public() + if e != nil { + t.Fatal(e) + } + output, e := json.Marshal(struct { + Public ProvisionalPublic `json:"public"` + OwnerDER []byte `json:"owner_der"` + }{public, source.source.OwnerDER}) + if e != nil { + t.Fatal(e) + } + fmt.Println("ARBITER_PORTABLE_CODEC=" + string(output)) + } + hash := sha256.Sum256(source.source.OwnerDER) + if source.source.Fingerprint != hex.EncodeToString(hash[:]) { + t.Fatal("issuer hash") + } +} + +func TestStoredSourceBindingsAndVersionTwoReceipt(t *testing.T) { + p, raw, now := portableFixture(t, 0, 0) + root := custodyTestDir(t) + original := savePortableFixture(t, p, raw, root, "enrolled") + path := filepath.Join(root, "enrolled", p.record.Selector+".json") + for _, offset := range []int{0, len(preparedTag), len(preparedTag) + 36, len(preparedTag) + 72, len(preparedTag) + 144 + 22} { + var record preparedPrivateRecord + json.Unmarshal(original, &record) + record.Result = bytes.Clone(raw) + record.Result[offset] ^= 1 + bad, _ := json.Marshal(record) + if err := os.WriteFile(path, bad, 0600); err != nil { + t.Fatal(err) + } + if _, err := LoadPortableIdentity(root, p.record.Selector, now); err == nil { + t.Fatal("altered stored binding accepted", offset) + } + } + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + source, err := LoadPortableIdentity(root, p.record.Selector, now) + if err != nil { + t.Fatal(err) + } + target, _, err := NewProvisionalFromIdentity("https://target.invalid", "target", now, source) + if err != nil { + t.Fatal(err) + } + other, _, _ := NewProvisional("https://other.invalid", "other", now) + r := target.record + r.PortableKey = other.record.PortableKey + bad, _ := json.Marshal(r) + if _, err = DecodeProvisional(bad); err == nil { + t.Fatal("source key mismatch accepted") + } + p.record.Version = 2 + p.record.Source = json.RawMessage("null") + public, _ := p.Public() + ad, _ := json.Marshal(public) + hash := sha256.Sum256(ad) + raw = bytes.Clone(raw) + copy(raw[len(preparedTag)+144+22:], hash[:]) + v2Root := custodyTestDir(t) + savePortableFixture(t, p, raw, v2Root, "enrolled") + if _, _, err = loadPrepared(filepath.Join(v2Root, "enrolled"), p.record.Selector, now, true); err != nil { + t.Fatal("v2 receipt", err) + } + if _, err = LoadPortableIdentity(v2Root, p.record.Selector, now); err != nil { + t.Fatal("v2 source", err) + } +} diff --git a/client/go-cli/internal/identity/prepared.go b/client/go-cli/internal/identity/prepared.go index 2177c27..98315d2 100644 --- a/client/go-cli/internal/identity/prepared.go +++ b/client/go-cli/internal/identity/prepared.go @@ -19,7 +19,7 @@ import ( const preparedTag = "arbiter-enrollment-prepared-v1\x00" const readyTag = "arbiter-enrollment-ready-v1\x00" -const maxPreparedPrivateBytes = MaxProvisionalBytes + 2*MaxPairingFrame +const maxPreparedPrivateBytes = MaxPortableProvisionalBytes + 2*MaxPairingFrame type preparedPrivateRecord struct { Warning string `json:"warning"` @@ -67,6 +67,10 @@ func verifyHistoricalServerCredential(der []byte, expected Identity, issuer *Iss } func verifyPreparedResult(raw []byte, material *Provisional, server *PairingServer, now time.Time, receipt bool) (*PreparedEnrollment, error) { + return verifyPreparedPurpose(raw, material, server, now, receipt, false) +} + +func verifyPreparedPurpose(raw []byte, material *Provisional, server *PairingServer, now time.Time, receipt, source bool) (*PreparedEnrollment, error) { const fixed = len(preparedTag) + 144 + 22 + 32 + 32 + 8 if len(raw) < fixed || len(raw) > MaxPairingFrame-16 || !bytes.HasPrefix(raw, []byte(preparedTag)) || material == nil || server == nil { return nil, errPairing @@ -111,7 +115,7 @@ func verifyPreparedResult(raw []byte, material *Provisional, server *PairingServ return nil, errPairing } var serverErr error - if receipt { + if receipt || source { serverErr = verifyHistoricalServerCredential(fields[1], Identity{Role: ServerTLS, SubjectID: ids[0]}, &Issuer{DER: fields[0], ID: ids[0]}, now) } else { _, serverErr = VerifyCredential(fields[1], Identity{Role: ServerTLS, SubjectID: ids[0]}, &Issuer{DER: fields[0], ID: ids[0]}, now) @@ -119,7 +123,15 @@ func verifyPreparedResult(raw []byte, material *Provisional, server *PairingServ if serverErr != nil { return nil, serverErr } - if _, err := VerifyCredential(fields[2], Identity{Role: Owner, SubjectID: ids[1]}, nil, now); err != nil { + portableTime := now + if receipt && !source { + leaf, e := x509.ParseCertificate(fields[3]) + if e != nil || now.Before(leaf.NotBefore) { + return nil, errPairing + } + portableTime = leaf.NotBefore + } + if _, err := VerifyCredential(fields[2], Identity{Role: Owner, SubjectID: ids[1]}, nil, portableTime); err != nil { return nil, err } for _, item := range []struct { @@ -131,7 +143,17 @@ func verifyPreparedResult(raw []byte, material *Provisional, server *PairingServ {3, Identity{Role: Portable, SubjectID: ids[2]}, Issuer{DER: fields[2], ID: ids[1]}, &material.portable.PublicKey}, {4, Identity{Role: Access, SubjectID: ids[2], ServerID: ids[0], EnrollmentID: ids[3]}, Issuer{DER: fields[0], ID: ids[0]}, &material.access.PublicKey}, } { - cert, err := VerifyCredential(fields[item.index], item.identity, &item.issuer, now) + at := now + if item.index == 3 { + at = portableTime + } else if source { + leaf, e := x509.ParseCertificate(fields[item.index]) + if e != nil || now.Before(leaf.NotBefore) { + return nil, errPairing + } + at = leaf.NotBefore + } + cert, err := VerifyCredential(fields[item.index], item.identity, &item.issuer, at) if err != nil || !samePublicKey(cert.PublicKey, item.key) { return nil, errPairing } @@ -229,61 +251,69 @@ func loadPrepared(directory, selector string, now time.Time, receipt bool) (*Pro } func loadPreparedLinked(directory, selector string, now time.Time, receipt bool, links uint32) (*Provisional, *PreparedEnrollment, error) { - root, dir, err := custodyRoot(directory, false) + material, raw, server, err := loadStoredEnrollment(directory, selector, links) if err != nil { return nil, nil, err } + prepared, err := verifyPreparedResult(raw, material, server, now, receipt) + return material, prepared, err +} + +func loadStoredEnrollment(directory, selector string, links uint32) (*Provisional, []byte, *PairingServer, error) { + root, dir, err := custodyRoot(directory, false) + if err != nil { + return nil, nil, nil, err + } defer dir.Close() final, _, err := custodyNames(root, selector) if err != nil { - return nil, nil, err + return nil, nil, nil, err } f, err := nativeOpenCustody(final, false, false) if err != nil { - return nil, nil, err + return nil, nil, nil, err } defer f.Close() if err := checkBoundCustody(f, false, false, links); err != nil { - return nil, nil, err + return nil, nil, nil, err } bundle, err := io.ReadAll(io.LimitReader(f, int64(maxPreparedPrivateBytes+1))) if err != nil || len(bundle) > maxPreparedPrivateBytes || !utf8.Valid(bundle) { - return nil, nil, errCustody + return nil, nil, nil, errCustody } if err := checkBoundCustody(f, false, false, links); err != nil { - return nil, nil, err + return nil, nil, nil, err } var record preparedPrivateRecord if json.Unmarshal(bundle, &record) != nil || record.Warning != privateWarning || record.Version != 1 { - return nil, nil, errCustody + return nil, nil, nil, errCustody } // Only this writer's canonical representation is accepted. Duplicate, // unknown, differently cased and trailing fields cannot survive readback. canonical, err := json.Marshal(record) if err != nil || !bytes.Equal(canonical, bundle) { - return nil, nil, errCustody + return nil, nil, nil, errCustody } material, err := DecodeProvisional(record.Provisional) - if err != nil || material.record.Selector != selector { - return nil, nil, errCustody + if err != nil || material.record.Selector != selector || material.record.Version == 1 && len(bundle) > MaxProvisionalBytes+2*MaxPairingFrame { + return nil, nil, nil, errCustody } raw := record.Result const fixed = len(preparedTag) + 144 + 22 + 32 + 32 + 8 if len(raw) < fixed { - return nil, nil, errCustody + return nil, nil, nil, errCustody } fields := raw[fixed:] authority, err := preparedField(&fields) if err != nil { - return nil, nil, err + return nil, nil, nil, err } leaf, err := preparedField(&fields) if err != nil { - return nil, nil, err + return nil, nil, nil, err } server := &PairingServer{ServerID: string(raw[len(preparedTag) : len(preparedTag)+36]), AuthorityDER: authority, CertificateDER: leaf} - prepared, err := verifyPreparedResult(raw, material, server, now, receipt) - return material, prepared, err + return material, raw, server, nil } // CleanupPrepared uses the same bounded cleanup/ownership contract as provisional diff --git a/client/go-cli/internal/identity/provisional.go b/client/go-cli/internal/identity/provisional.go index eff6528..b1f30bf 100644 --- a/client/go-cli/internal/identity/provisional.go +++ b/client/go-cli/internal/identity/provisional.go @@ -19,6 +19,7 @@ import ( ) const MaxProvisionalBytes = 8192 +const MaxPortableProvisionalBytes = 24 * 1024 const privateWarning = "NEVER SHARE THIS: private client keys; possession permits impersonation." @@ -73,6 +74,8 @@ type provisionalRecord struct { AccessKey string `json:"access_key_pkcs8"` PortableKey string `json:"portable_key_pkcs8"` RecipientKey string `json:"recipient_key_pkcs8"` + + Source json.RawMessage `json:"source_identity,omitempty"` } // Provisional contains no approved agent ID, certificate, grant or server trust. @@ -91,6 +94,8 @@ type ProvisionalPublic struct { AccessKey []byte `json:"access_spki"` PortableKey []byte `json:"portable_spki"` RecipientKey []byte `json:"recipient_spki"` + + PortableIdentity json.RawMessage `json:"portable_identity,omitempty"` } func (Provisional) String() string { return "" } @@ -103,7 +108,7 @@ func validateProvisionalMetadata(r provisionalRecord) error { u, err := url.Parse(r.ServerURL) _, offset := r.CreatedAt.Zone() if err != nil || len(r.ServerURL) > 2048 || !utf8.ValidString(r.ServerURL) || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || - r.Version != 1 || r.Warning != privateWarning || r.CreatedAt.IsZero() || offset != 0 || + (r.Version != 1 && r.Version != 2) || r.Warning != privateWarning || r.CreatedAt.IsZero() || offset != 0 || len(r.Name) == 0 || len(r.Name) > 256 || strings.TrimSpace(r.Name) != r.Name || !utf8.ValidString(r.Name) || strings.IndexFunc(r.Name, unicode.IsControl) >= 0 { return errProvisional } @@ -173,7 +178,21 @@ func (p *Provisional) Public() (ProvisionalPublic, error) { return ProvisionalPublic{}, err } recipient, err := x509.MarshalPKIXPublicKey(p.recipient.PublicKey()) - return ProvisionalPublic{1, p.record.Selector, p.record.Name, access, portable, recipient}, err + public := ProvisionalPublic{Version: p.record.Version, Selector: p.record.Selector, Name: p.record.Name, AccessKey: access, PortableKey: portable, RecipientKey: recipient} + if p.record.Version == 2 { + public.PortableIdentity = json.RawMessage("null") + if source, e := decodePortableSource(p.record.Source, p.portable, nil); e != nil { + return ProvisionalPublic{}, e + } else if source != nil { + public.PortableIdentity, err = json.Marshal(struct { + OwnerID string `json:"owner_id"` + AgentID string `json:"agent_id"` + Fingerprint string `json:"issuer_sha256"` + Certificate []byte `json:"certificate_der"` + }{source.OwnerID, source.AgentID, source.Fingerprint, source.Certificate}) + } + } + return public, err } func decodeProvisionalKey(encoded string) (any, error) { @@ -196,7 +215,7 @@ func decodeProvisionalKey(encoded string) (any, error) { } func DecodeProvisional(data []byte) (*Provisional, error) { - if len(data) == 0 || len(data) > MaxProvisionalBytes || !utf8.Valid(data) { + if len(data) == 0 || len(data) > MaxPortableProvisionalBytes || !utf8.Valid(data) { return nil, errProvisional } // encoding/json alone accepts duplicate object keys. Reject them explicitly. @@ -206,7 +225,7 @@ func DecodeProvisional(data []byte) (*Provisional, error) { return nil, errProvisional } seen := make(map[string]bool) - allowed := map[string]bool{"warning": true, "version": true, "selector": true, "server_url": true, "name": true, "created_at": true, "access_key_pkcs8": true, "portable_key_pkcs8": true, "recipient_key_pkcs8": true} + allowed := map[string]bool{"warning": true, "version": true, "selector": true, "server_url": true, "name": true, "created_at": true, "access_key_pkcs8": true, "portable_key_pkcs8": true, "recipient_key_pkcs8": true, "source_identity": true} for d.More() { token, err = d.Token() name, ok := token.(string) @@ -228,7 +247,9 @@ func DecodeProvisional(data []byte) (*Provisional, error) { var r provisionalRecord d = json.NewDecoder(bytes.NewReader(data)) d.DisallowUnknownFields() - if d.Decode(&r) != nil || validateProvisionalMetadata(r) != nil { + if d.Decode(&r) != nil || validateProvisionalMetadata(r) != nil || + r.Version == 1 && (len(data) > MaxProvisionalBytes || seen["source_identity"]) || + r.Version == 2 && !seen["source_identity"] { return nil, errProvisional } a, err := decodeProvisionalKey(r.AccessKey) @@ -249,5 +270,10 @@ func DecodeProvisional(data []byte) (*Provisional, error) { if !aok || !bok || !cok || access.Curve != elliptic.P256() || portable.Curve != elliptic.P256() || recipient.Curve() != ecdh.X25519() || samePublicKey(access.Public(), portable.Public()) { return nil, errProvisional } + if r.Version == 2 { + if _, err := decodePortableSource(r.Source, portable, nil); err != nil { + return nil, err + } + } return &Provisional{r, access, portable, recipient}, nil } diff --git a/client/go-cli/internal/identity/provisional_store.go b/client/go-cli/internal/identity/provisional_store.go index 9726bdc..b45f9f3 100644 --- a/client/go-cli/internal/identity/provisional_store.go +++ b/client/go-cli/internal/identity/provisional_store.go @@ -193,7 +193,7 @@ func loadProvisional(directory, selector string, links uint32) (*Provisional, er if err := checkBoundCustody(f, false, false, links); err != nil { return nil, err } - raw, err := io.ReadAll(io.LimitReader(f, MaxProvisionalBytes+1)) + raw, err := io.ReadAll(io.LimitReader(f, MaxPortableProvisionalBytes+1)) if err != nil { return nil, err } diff --git a/docs/enrollment.md b/docs/enrollment.md index 18957d7..aa1aa57 100644 --- a/docs/enrollment.md +++ b/docs/enrollment.md @@ -40,3 +40,10 @@ state, and binds each existing agent to its enrollment. Invalid or duplicate historical bindings stop startup and preserve the old database. Preserve the state and resolve the reported error; do not delete authority files to retry. New fresh enrollments maintain that binding in the same activation transaction. + +The portable identity codec groundwork validates a source only from complete +`enrolled/` custody. It checks the owner and portable certificate at the current +time without contacting the source server or requiring its access certificate +to remain valid. Saved receipt recovery checks portable evidence at issuance; +this does not permit an expired portable identity to start another enrollment. +The public CLI continues to create fresh identities at this intermediate cut. diff --git a/server/news/+portable-identity-codecs.feature.md b/server/news/+portable-identity-codecs.feature.md new file mode 100644 index 0000000..2263d93 --- /dev/null +++ b/server/news/+portable-identity-codecs.feature.md @@ -0,0 +1 @@ +Add internal portable identity source validation and versioned enrollment codecs while preserving saved receipt recovery. Public enrollment continues to use the existing fresh identity flow until the portable enrollment switch is complete. diff --git a/server/src/arbiter_server/enrollment.py b/server/src/arbiter_server/enrollment.py index 80999c9..23a3a04 100644 --- a/server/src/arbiter_server/enrollment.py +++ b/server/src/arbiter_server/enrollment.py @@ -8,11 +8,13 @@ import hashlib import hmac import json +import re import threading import time import unicodedata -from uuid import uuid4 +from uuid import UUID, uuid4 +from cryptography import x509 from cryptography.exceptions import UnsupportedAlgorithm from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, x25519 @@ -59,6 +61,55 @@ def _decode64(value: object, size: int, *, url: bool = False) -> bytes: return raw +@dataclass(frozen=True) +class PortableIdentityClaim: + owner_id: str + agent_id: str + issuer_sha256: str + certificate_der: bytes + + +def _portable_claim( + value: object, portable_spki: bytes +) -> PortableIdentityClaim | None: + if value is None: + return None + if not isinstance(value, dict) or set(value) != { + "owner_id", + "agent_id", + "issuer_sha256", + "certificate_der", + }: + raise ValueError("portable claim shape") + owner, agent = value["owner_id"], value["agent_id"] + for identity in (owner, agent): + if not isinstance(identity, str) or str(UUID(identity, version=4)) != identity: + raise ValueError("portable identity") + fingerprint, encoded = value["issuer_sha256"], value["certificate_der"] + if ( + owner == agent + or not isinstance(fingerprint, str) + or re.fullmatch(r"[0-9a-f]{64}", fingerprint) is None + ): + raise ValueError("portable issuer fingerprint") + if not isinstance(encoded, str) or len(encoded) > 5464: + raise ValueError("portable certificate bound") + der = base64.b64decode(encoded, validate=True) + if not 0 < len(der) <= 4096 or base64.b64encode(der).decode() != encoded: + raise ValueError("portable certificate encoding") + certificate = x509.load_der_x509_certificate(der) + if ( + certificate.public_bytes(serialization.Encoding.DER) != der + or certificate.public_key().public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo + ) + != portable_spki + ): + raise ValueError("portable certificate key") + # Anonymous decoding supplies no issuer trust, profile or possession proof. + return PortableIdentityClaim(owner, agent, fingerprint, der) + + @dataclass(frozen=True) class Advertisement: selector: str @@ -67,9 +118,10 @@ class Advertisement: portable_spki: bytes recipient_spki: bytes digest: str + portable_identity: PortableIdentityClaim | None = None -def decode_advertisement(raw: bytes | bytearray) -> Advertisement: +def decode_advertisement(raw: bytes | bytearray, *, version: int = 1) -> Advertisement: try: if not raw or len(raw) > MAX_ADVERTISEMENT_BYTES: raise ValueError("advertisement size") @@ -85,11 +137,14 @@ def decode_advertisement(raw: bytes | bytearray) -> Advertisement: "portable_spki", "recipient_spki", } + if version == 2: + fields.add("portable_identity") if ( - not isinstance(value, dict) + version not in (1, 2) + or not isinstance(value, dict) or set(value) != fields or type(value["version"]) is not int - or value["version"] != 1 + or value["version"] != version ): raise ValueError("advertisement shape") selector, name = value["selector"], value["name"] @@ -130,7 +185,12 @@ def decode_advertisement(raw: bytes | bytearray) -> Advertisement: # Bind the exact accepted wire bytes. Both peers can compute this # without relying on language-specific JSON canonicalization. digest = hashlib.sha256(raw).hexdigest() - return Advertisement(selector, name, keys[0], keys[1], keys[2], digest) + claim = ( + _portable_claim(value["portable_identity"], keys[1]) + if version == 2 + else None + ) + return Advertisement(selector, name, keys[0], keys[1], keys[2], digest, claim) except ( ValueError, TypeError, diff --git a/server/tests/integration/test_provisional_peer.py b/server/tests/integration/test_provisional_peer.py index a94f1c7..e3e8546 100644 --- a/server/tests/integration/test_provisional_peer.py +++ b/server/tests/integration/test_provisional_peer.py @@ -160,3 +160,83 @@ def test_fresh_go_public_request_reaches_python_server(tmp_path) -> None: assert app.state.enrollment_requests.pending() == () with pytest.raises(OperationError, match="Enrollment is closed"): app.state.enrollment_requests.require_open() + + +def test_go_portable_codec_is_strict_and_public_registration_stays_v1() -> None: + from arbiter_server.enrollment import decode_advertisement + from arbiter_server.credentials import Identity, Role, verify_certificate + + if shutil.which("go") is None: + pytest.skip("go is not installed") + result = subprocess.run( + [ + "go", + "test", + "-count=1", + "-run", + "^TestPythonPortableCodecPeer$", + "-v", + "./internal/identity", + ], + cwd=Path(__file__).resolve().parents[3] / "client" / "go-cli", + env={**os.environ, "ARBITER_TEST_PORTABLE_CODEC": "1"}, + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, result.stdout + result.stderr + rows = [ + json.loads(line.removeprefix("ARBITER_PORTABLE_CODEC=")) + for line in result.stdout.splitlines() + if line.startswith("ARBITER_PORTABLE_CODEC=") + ] + assert len(rows) == 2 + for row in rows: + public = row["public"] + raw = json.dumps(public, separators=(",", ":")).encode() + decoded = decode_advertisement(raw, version=2) + assert ( + public["version"] == 2 and decoded.digest == hashlib.sha256(raw).hexdigest() + ) + with pytest.raises(OperationError): + decode_advertisement(raw) + claim = decoded.portable_identity + if public["portable_identity"] is None: + assert claim is None + else: + assert claim is not None + root = base64.b64decode(row["owner_der"]) + assert hashlib.sha256(root).hexdigest() == claim.issuer_sha256 + from datetime import datetime, timezone + + verify_certificate( + claim.certificate_der, + Identity(Role.PORTABLE, claim.agent_id), + issuer_der=root, + issuer_id=claim.owner_id, + now=datetime.now(timezone.utc), + ) + # Decoding claims alone grants neither registration nor issuer trust. + for field, value in [ + ("owner_id", "invalid"), + ("agent_id", claim.owner_id), + ("issuer_sha256", "F" * 64), + ("certificate_der", "eA=="), + ("extra", "x"), + ]: + changed = json.loads(raw) + changed["portable_identity"][field] = value + with pytest.raises(OperationError): + decode_advertisement(json.dumps(changed).encode(), version=2) + duplicate = raw.replace( + b'"owner_id":', b'"owner_id":"duplicate","owner_id":' + ) + with pytest.raises(OperationError): + decode_advertisement(duplicate, version=2) + for changed in [ + raw + b"x", + raw.replace(b'"version":2', b'"version":true'), + raw.replace(b'"portable_identity":', b'"Portable_Identity":'), + ]: + with pytest.raises(OperationError): + decode_advertisement(changed, version=2)