diff --git a/internal/delegation/identity.go b/internal/delegation/identity.go index c6698196..2860e308 100644 --- a/internal/delegation/identity.go +++ b/internal/delegation/identity.go @@ -39,26 +39,62 @@ type CreateOrConfirmRequest struct { IdempotencyKey string `json:"idempotency_key"` } +// delegationBinding is the request/identity tuple that must remain identical +// across create, confirm, and restart recovery. +type delegationBinding struct { + RunID string `json:"run_id"` + EnclaveBackend string `json:"enclave_backend"` + EnclaveEntryID string `json:"enclave_entry_id"` + InvocationID string `json:"invocation_id"` + Repository string `json:"repository"` + ToolPolicy string `json:"tool_policy"` + SchemaHash string `json:"schema_hash"` + AdmittedDefaultBranchSHA string `json:"admitted_default_branch_sha,omitempty"` + InvocationExpiresAt time.Time `json:"invocation_expires_at,omitempty"` +} + +func bindingFromRequest(req CreateOrConfirmRequest) delegationBinding { + return delegationBinding{ + RunID: req.RunID, + EnclaveBackend: req.EnclaveBackend, + EnclaveEntryID: req.EnclaveEntryID, + InvocationID: req.InvocationID, + Repository: req.Repository, + ToolPolicy: req.ToolPolicy, + SchemaHash: req.SchemaHash, + AdmittedDefaultBranchSHA: req.AdmittedDefaultBranchSHA, + InvocationExpiresAt: req.InvocationExpiresAt.Round(0).UTC(), + } +} + +func (b delegationBinding) toRequest(requestedTTL time.Duration, idempotencyKey string) CreateOrConfirmRequest { + return CreateOrConfirmRequest{ + RunID: b.RunID, + EnclaveBackend: b.EnclaveBackend, + EnclaveEntryID: b.EnclaveEntryID, + InvocationID: b.InvocationID, + Repository: b.Repository, + ToolPolicy: b.ToolPolicy, + SchemaHash: b.SchemaHash, + AdmittedDefaultBranchSHA: b.AdmittedDefaultBranchSHA, + RequestedTTL: requestedTTL, + InvocationExpiresAt: b.InvocationExpiresAt, + IdempotencyKey: idempotencyKey, + } +} + // Identity is one invocation-scoped delegated identity bound to a single // canonical repository under github-repository-read-v1. type Identity struct { - Handle string `json:"handle"` - ExecutorBearer string `json:"executor_bearer"` - RunID string `json:"run_id"` - EnclaveBackend string `json:"enclave_backend"` - EnclaveEntryID string `json:"enclave_entry_id"` - InvocationID string `json:"invocation_id"` - Repository string `json:"repository"` - ToolPolicy string `json:"tool_policy"` - SchemaHash string `json:"schema_hash"` - AdmittedDefaultBranchSHA string `json:"admitted_default_branch_sha,omitempty"` - RequestedTTL time.Duration `json:"requested_ttl,omitempty"` - ExpiresAt time.Time `json:"expires_at"` - InvocationExpiresAt time.Time `json:"invocation_expires_at,omitempty"` - PolicyGeneration uint64 `json:"policy_generation"` - IdempotencyKey string `json:"idempotency_key"` - CreatedAt time.Time `json:"created_at"` - Revoked bool `json:"revoked"` + Handle string `json:"handle"` + ExecutorBearer string `json:"executor_bearer"` + delegationBinding + RequestedTTL time.Duration `json:"requested_ttl,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + PolicyGeneration uint64 `json:"policy_generation"` + IdempotencyKey string `json:"idempotency_key"` + CreatedAt time.Time `json:"created_at"` + Revoked bool `json:"revoked"` } // invocationScopeKey returns the compound key CreateOrConfirm dedupes on: @@ -115,14 +151,17 @@ func (id *Identity) toResult() *IdentityResult { // by toResult) always stands. Per the ADR, any mismatch here is terminal: the // caller must revoke any partial identity and fail the request. func (id *Identity) bindingEquals(req CreateOrConfirmRequest) bool { - return id.RunID == req.RunID && - id.EnclaveBackend == req.EnclaveBackend && - id.EnclaveEntryID == req.EnclaveEntryID && - id.InvocationID == req.InvocationID && - id.Repository == req.Repository && - id.ToolPolicy == req.ToolPolicy && - id.SchemaHash == req.SchemaHash && - id.AdmittedDefaultBranchSHA == req.AdmittedDefaultBranchSHA && - id.RequestedTTL == req.RequestedTTL && - id.InvocationExpiresAt.Equal(req.InvocationExpiresAt) + return id.binding() == bindingFromRequest(req) && id.RequestedTTL == req.RequestedTTL +} + +func (id *Identity) binding() delegationBinding { + return id.delegationBinding +} + +func (id *Identity) toRequest() CreateOrConfirmRequest { + requestedTTL := id.RequestedTTL + if requestedTTL == 0 { + requestedTTL = id.ExpiresAt.Sub(id.CreatedAt) + } + return id.delegationBinding.toRequest(requestedTTL, id.IdempotencyKey) } diff --git a/internal/delegation/identity_test.go b/internal/delegation/identity_test.go new file mode 100644 index 00000000..98210bb6 --- /dev/null +++ b/internal/delegation/identity_test.go @@ -0,0 +1,25 @@ +package delegation + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIdentityBindingRoundTrip(t *testing.T) { + req := validRequest() + req.InvocationExpiresAt = time.Now().Add(time.Minute).In(time.FixedZone("test", 3600)) + identity := &Identity{ + delegationBinding: bindingFromRequest(req), + ExpiresAt: time.Now().Add(30 * time.Second), + IdempotencyKey: req.IdempotencyKey, + CreatedAt: time.Now(), + } + + restored := identity.toRequest() + require.Equal(t, req.IdempotencyKey, restored.IdempotencyKey) + assert.Equal(t, bindingFromRequest(req), bindingFromRequest(restored)) + assert.Equal(t, identity.ExpiresAt.Sub(identity.CreatedAt), restored.RequestedTTL) +} diff --git a/internal/delegation/recovery.go b/internal/delegation/recovery.go index bbe57437..a5e66d9c 100644 --- a/internal/delegation/recovery.go +++ b/internal/delegation/recovery.go @@ -349,19 +349,7 @@ func validateRestoredIdentity(identity *Identity, envelope *Envelope, generation if !identity.InvocationExpiresAt.IsZero() && identity.ExpiresAt.After(identity.InvocationExpiresAt) { return fmt.Errorf("identity expiry exceeds invocation expiry") } - return (&Store{envelope: envelope}).validateAgainstEnvelope(CreateOrConfirmRequest{ - RunID: identity.RunID, - EnclaveBackend: identity.EnclaveBackend, - EnclaveEntryID: identity.EnclaveEntryID, - InvocationID: identity.InvocationID, - Repository: identity.Repository, - ToolPolicy: identity.ToolPolicy, - SchemaHash: identity.SchemaHash, - AdmittedDefaultBranchSHA: identity.AdmittedDefaultBranchSHA, - RequestedTTL: identity.ExpiresAt.Sub(identity.CreatedAt), - InvocationExpiresAt: identity.InvocationExpiresAt, - IdempotencyKey: identity.IdempotencyKey, - }, identity.CreatedAt) + return (&Store{envelope: envelope}).validateAgainstEnvelope(identity.toRequest(), identity.CreatedAt) } // parsePersistedState verifies the trailing checksum and decodes the JSON diff --git a/internal/delegation/recovery_test.go b/internal/delegation/recovery_test.go index a8c06839..bf393d8c 100644 --- a/internal/delegation/recovery_test.go +++ b/internal/delegation/recovery_test.go @@ -155,20 +155,22 @@ func TestLoadStore_FailsClosedOnLegacyVersion(t *testing.T) { // Construct a legacy version 1 state file payload now := time.Now() id := Identity{ - Handle: "dlg_legacy123", - ExecutorBearer: "dlgbearer_legacy123", - RunID: "run-123", - EnclaveBackend: "awf-enclave", - EnclaveEntryID: "entry-1", - InvocationID: "inv-1", - Repository: "github/gh-aw", - ToolPolicy: ToolPolicyGitHubRepositoryReadV1, - SchemaHash: "sha256:abc", - ExpiresAt: now.Add(time.Hour), - InvocationExpiresAt: now.Add(time.Hour), - PolicyGeneration: 1, - IdempotencyKey: "idem-1", - CreatedAt: now, + delegationBinding: delegationBinding{ + RunID: "run-123", + EnclaveBackend: "awf-enclave", + EnclaveEntryID: "entry-1", + InvocationID: "inv-1", + Repository: "github/gh-aw", + ToolPolicy: ToolPolicyGitHubRepositoryReadV1, + SchemaHash: "sha256:abc", + InvocationExpiresAt: now.Add(time.Hour), + }, + Handle: "dlg_legacy123", + ExecutorBearer: "dlgbearer_legacy123", + ExpiresAt: now.Add(time.Hour), + PolicyGeneration: 1, + IdempotencyKey: "idem-1", + CreatedAt: now, } body := fmt.Sprintf(`{"version":1,"generation":1,"recovery_incomplete":false,"identities":{"dlg_legacy123":%s}}`, marshalJSON(id)) writeStateWithChecksum(t, path, []byte(body)) @@ -185,36 +187,40 @@ func TestLoadStore_FailsClosedOnDuplicateLiveInvocationKeys(t *testing.T) { now := time.Now() id1 := Identity{ - Handle: "dlg_handle1", - ExecutorBearer: "dlgbearer_bearer1", - RunID: "run-123", - EnclaveBackend: "awf-enclave", - EnclaveEntryID: "entry-1", - InvocationID: "inv-1", - Repository: "github/gh-aw", - ToolPolicy: ToolPolicyGitHubRepositoryReadV1, - SchemaHash: "sha256:abc", - ExpiresAt: now.Add(time.Hour), - InvocationExpiresAt: now.Add(time.Hour), - PolicyGeneration: 1, - IdempotencyKey: "idem-1", - CreatedAt: now, + delegationBinding: delegationBinding{ + RunID: "run-123", + EnclaveBackend: "awf-enclave", + EnclaveEntryID: "entry-1", + InvocationID: "inv-1", + Repository: "github/gh-aw", + ToolPolicy: ToolPolicyGitHubRepositoryReadV1, + SchemaHash: "sha256:abc", + InvocationExpiresAt: now.Add(time.Hour), + }, + Handle: "dlg_handle1", + ExecutorBearer: "dlgbearer_bearer1", + ExpiresAt: now.Add(time.Hour), + PolicyGeneration: 1, + IdempotencyKey: "idem-1", + CreatedAt: now, } id2 := Identity{ - Handle: "dlg_handle2", - ExecutorBearer: "dlgbearer_bearer2", - RunID: "run-123", - EnclaveBackend: "awf-enclave", - EnclaveEntryID: "entry-1", - InvocationID: "inv-1", // same invocation ID - Repository: "github/gh-aw", - ToolPolicy: ToolPolicyGitHubRepositoryReadV1, - SchemaHash: "sha256:abc", - ExpiresAt: now.Add(time.Hour), - InvocationExpiresAt: now.Add(time.Hour), - PolicyGeneration: 1, - IdempotencyKey: "idem-2", - CreatedAt: now, + delegationBinding: delegationBinding{ + RunID: "run-123", + EnclaveBackend: "awf-enclave", + EnclaveEntryID: "entry-1", + InvocationID: "inv-1", // same invocation ID + Repository: "github/gh-aw", + ToolPolicy: ToolPolicyGitHubRepositoryReadV1, + SchemaHash: "sha256:abc", + InvocationExpiresAt: now.Add(time.Hour), + }, + Handle: "dlg_handle2", + ExecutorBearer: "dlgbearer_bearer2", + ExpiresAt: now.Add(time.Hour), + PolicyGeneration: 1, + IdempotencyKey: "idem-2", + CreatedAt: now, } body := fmt.Sprintf(`{"version":%d,"generation":1,"recovery_incomplete":false,"identities":{"h1":%s,"h2":%s}}`, statePersistVersion, marshalJSON(id1), marshalJSON(id2)) @@ -243,37 +249,41 @@ func TestLoadStore_FailsClosedOnDuplicateLiveAndTerminalInvocationKeys(t *testin now := time.Now() live := Identity{ - Handle: "dlg_live", - ExecutorBearer: "dlgbearer_live", - RunID: "run-123", - EnclaveBackend: "awf-enclave", - EnclaveEntryID: "entry-1", - InvocationID: "inv-1", - Repository: "github/gh-aw", - ToolPolicy: ToolPolicyGitHubRepositoryReadV1, - SchemaHash: "sha256:abc", - ExpiresAt: now.Add(time.Hour), - InvocationExpiresAt: now.Add(time.Hour), - PolicyGeneration: 1, - IdempotencyKey: "idem-1", - CreatedAt: now, + delegationBinding: delegationBinding{ + RunID: "run-123", + EnclaveBackend: "awf-enclave", + EnclaveEntryID: "entry-1", + InvocationID: "inv-1", + Repository: "github/gh-aw", + ToolPolicy: ToolPolicyGitHubRepositoryReadV1, + SchemaHash: "sha256:abc", + InvocationExpiresAt: now.Add(time.Hour), + }, + Handle: "dlg_live", + ExecutorBearer: "dlgbearer_live", + ExpiresAt: now.Add(time.Hour), + PolicyGeneration: 1, + IdempotencyKey: "idem-1", + CreatedAt: now, } terminal := Identity{ - Handle: "dlg_terminal", - ExecutorBearer: "dlgbearer_terminal", - RunID: "run-123", - EnclaveBackend: "awf-enclave", - EnclaveEntryID: "entry-1", - InvocationID: "inv-1", // same invocation ID - Repository: "github/gh-aw", - ToolPolicy: ToolPolicyGitHubRepositoryReadV1, - SchemaHash: "sha256:abc", - ExpiresAt: now.Add(time.Hour), - InvocationExpiresAt: now.Add(time.Hour), - PolicyGeneration: 1, - IdempotencyKey: "idem-2", - CreatedAt: now, - Revoked: true, + delegationBinding: delegationBinding{ + RunID: "run-123", + EnclaveBackend: "awf-enclave", + EnclaveEntryID: "entry-1", + InvocationID: "inv-1", // same invocation ID + Repository: "github/gh-aw", + ToolPolicy: ToolPolicyGitHubRepositoryReadV1, + SchemaHash: "sha256:abc", + InvocationExpiresAt: now.Add(time.Hour), + }, + Handle: "dlg_terminal", + ExecutorBearer: "dlgbearer_terminal", + ExpiresAt: now.Add(time.Hour), + PolicyGeneration: 1, + IdempotencyKey: "idem-2", + CreatedAt: now, + Revoked: true, } body := fmt.Sprintf(`{"version":%d,"generation":1,"recovery_incomplete":false,"identities":{"h1":%s,"h2":%s}}`, statePersistVersion, marshalJSON(live), marshalJSON(terminal)) @@ -301,36 +311,40 @@ func TestLoadStore_FailsClosedWhenUniqueDynamicSchemaHashesExceedEnvelopeBound(t now := time.Now() id1 := Identity{ - Handle: "dlg_1", - ExecutorBearer: "dlgbearer_1", - RunID: "run-123", - EnclaveBackend: "awf-enclave", - EnclaveEntryID: "entry-1", - InvocationID: "inv-1", - Repository: "github/gh-aw", - ToolPolicy: ToolPolicyGitHubRepositoryReadV1, - SchemaHash: "sha256:hash1", - ExpiresAt: now.Add(time.Hour), - InvocationExpiresAt: now.Add(time.Hour), - PolicyGeneration: 1, - IdempotencyKey: "idem-1", - CreatedAt: now, + delegationBinding: delegationBinding{ + RunID: "run-123", + EnclaveBackend: "awf-enclave", + EnclaveEntryID: "entry-1", + InvocationID: "inv-1", + Repository: "github/gh-aw", + ToolPolicy: ToolPolicyGitHubRepositoryReadV1, + SchemaHash: "sha256:hash1", + InvocationExpiresAt: now.Add(time.Hour), + }, + Handle: "dlg_1", + ExecutorBearer: "dlgbearer_1", + ExpiresAt: now.Add(time.Hour), + PolicyGeneration: 1, + IdempotencyKey: "idem-1", + CreatedAt: now, } id2 := Identity{ - Handle: "dlg_2", - ExecutorBearer: "dlgbearer_2", - RunID: "run-123", - EnclaveBackend: "awf-enclave", - EnclaveEntryID: "entry-1", - InvocationID: "inv-2", - Repository: "github/gh-aw", - ToolPolicy: ToolPolicyGitHubRepositoryReadV1, - SchemaHash: "sha256:hash2", - ExpiresAt: now.Add(time.Hour), - InvocationExpiresAt: now.Add(time.Hour), - PolicyGeneration: 1, - IdempotencyKey: "idem-2", - CreatedAt: now, + delegationBinding: delegationBinding{ + RunID: "run-123", + EnclaveBackend: "awf-enclave", + EnclaveEntryID: "entry-1", + InvocationID: "inv-2", + Repository: "github/gh-aw", + ToolPolicy: ToolPolicyGitHubRepositoryReadV1, + SchemaHash: "sha256:hash2", + InvocationExpiresAt: now.Add(time.Hour), + }, + Handle: "dlg_2", + ExecutorBearer: "dlgbearer_2", + ExpiresAt: now.Add(time.Hour), + PolicyGeneration: 1, + IdempotencyKey: "idem-2", + CreatedAt: now, } // 2 unique schema hashes across identities > bound of 1 @@ -596,20 +610,22 @@ func TestSaveState_ConcurrentCallsRemainConsistent(t *testing.T) { // validEnvelope() so tests only need to vary the fields under test. func persistedIdentity(handle, bearer, invocationID string, now time.Time) Identity { return Identity{ - Handle: handle, - ExecutorBearer: bearer, - RunID: "run-123", - EnclaveBackend: "awf-enclave", - EnclaveEntryID: "entry-1", - InvocationID: invocationID, - Repository: "github/gh-aw", - ToolPolicy: ToolPolicyGitHubRepositoryReadV1, - SchemaHash: "sha256:abc", - ExpiresAt: now.Add(time.Hour), - InvocationExpiresAt: now.Add(time.Hour), - PolicyGeneration: 1, - IdempotencyKey: "idem-" + handle, - CreatedAt: now, + delegationBinding: delegationBinding{ + RunID: "run-123", + EnclaveBackend: "awf-enclave", + EnclaveEntryID: "entry-1", + InvocationID: invocationID, + Repository: "github/gh-aw", + ToolPolicy: ToolPolicyGitHubRepositoryReadV1, + SchemaHash: "sha256:abc", + InvocationExpiresAt: now.Add(time.Hour), + }, + Handle: handle, + ExecutorBearer: bearer, + ExpiresAt: now.Add(time.Hour), + PolicyGeneration: 1, + IdempotencyKey: "idem-" + handle, + CreatedAt: now, } } diff --git a/internal/delegation/store.go b/internal/delegation/store.go index be5475d3..6213e833 100644 --- a/internal/delegation/store.go +++ b/internal/delegation/store.go @@ -217,23 +217,16 @@ func (s *Store) createOrConfirmAt(req CreateOrConfirmRequest, now time.Time) (*I return nil, err } + binding := bindingFromRequest(req) identity := &Identity{ - Handle: handle, - ExecutorBearer: bearer, - RunID: req.RunID, - EnclaveBackend: req.EnclaveBackend, - EnclaveEntryID: req.EnclaveEntryID, - InvocationID: req.InvocationID, - Repository: req.Repository, - ToolPolicy: req.ToolPolicy, - SchemaHash: req.SchemaHash, - AdmittedDefaultBranchSHA: req.AdmittedDefaultBranchSHA, - RequestedTTL: req.RequestedTTL, - ExpiresAt: identityExpiry(now, req.RequestedTTL, s.envelope.ExpiresAt, req.InvocationExpiresAt), - InvocationExpiresAt: req.InvocationExpiresAt, - PolicyGeneration: s.generation, - IdempotencyKey: req.IdempotencyKey, - CreatedAt: now, + Handle: handle, + ExecutorBearer: bearer, + delegationBinding: binding, + RequestedTTL: req.RequestedTTL, + ExpiresAt: identityExpiry(now, req.RequestedTTL, s.envelope.ExpiresAt, req.InvocationExpiresAt), + PolicyGeneration: s.generation, + IdempotencyKey: req.IdempotencyKey, + CreatedAt: now, } s.indexLocked(identity)