Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions client/go-cli/internal/identity/portable.go
Original file line number Diff line number Diff line change
@@ -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 "<portable private identity redacted>" }
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
}
Comment on lines +98 to +103

@gitar-bot gitar-bot Bot Oct 30, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: Same-server check compares raw URL strings, easy to bypass

NewProvisionalFromIdentity refuses to reuse a source identity on the same server with source.serverURL == serverURL, which is an exact string comparison. validateProvisionalMetadata accepts any https URL that has a host, so https://source.invalid/, https://SOURCE.invalid or https://source.invalid:443 all point at the source server but pass this check. The guard tested by same target accepted therefore holds only when the URL is spelled exactly the same way. Fix: compare a normalized origin (lowercased scheme and host, default port removed, path ignored), or better, compare against the source's bound server ID when the target server ID is known.

Compare normalized origins instead of raw strings, and fail closed if either URL doesn't parse.:

func sameOrigin(a, b string) bool {
	ua, ea := url.Parse(a)
	ub, eb := url.Parse(b)
	if ea != nil || eb != nil {
		return true
	}
	port := func(u *url.URL) string { if p := u.Port(); p != "" { return p }; return "443" }
	return strings.EqualFold(ua.Scheme, ub.Scheme) && strings.EqualFold(ua.Hostname(), ub.Hostname()) && port(ua) == port(ub)
}

Was this helpful? React with 👍 / 👎

}
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
}
Loading
Loading