diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index d94ffc49..a33f2d19 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -50,6 +50,15 @@ jobs: with: go-version-file: ${{ matrix.module }}/go.mod cache-dependency-path: ${{ matrix.module }}/go.sum + # The hop library's single-use admission regressions run over the real + # transport and a real Postgres: the runner's own, started here, reached + # over its socket as the runner's user, with no password written down. + - name: start Postgres for the hop admission regressions + if: matrix.module == 'libraries/hop/go' + run: | + sudo systemctl start postgresql.service + sudo -u postgres createuser --superuser "$USER" + echo "HOP_TEST_POSTGRES=host=/var/run/postgresql dbname=postgres" >> "$GITHUB_ENV" - name: go vet run: go vet ./... - name: go test -race diff --git a/README.md b/README.md index 8fc4dfaf..6fec267b 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ staging validated. **Rung 1 first; it is the one that matters (owner, 2026-10-21).** The loop: 1. Cut a branch in module-saas-starter. accounts declares in its module - interface that it implements `codefly.dev/authority@0.3.1`, `codefly.dev/composition@0.2.0`, + interface that it implements `codefly.dev/authority@0.3.2`, `codefly.dev/composition@0.2.0`, `codefly.dev/installation@0.1.0` and `codefly.dev/session@0.2.0`, takes this repo's protos (bindings via `codefly generate proto`), and serves the four services as adapters over what it already has: `Issue` → StartTask / StartInstallationTask / @@ -276,7 +276,7 @@ Gap 7 was a decision, taken on 2026-10-22: the interface carries the host's name ## The namespace, settled (2026-10-22) The interfaces are the host's contract, so they carry the host's names: publisher -`codefly.dev` (ids `codefly.dev/authority@0.3.1`, `composition`, `installation`, +`codefly.dev` (ids `codefly.dev/authority@0.3.2`, `composition`, `installation`, `installable`, `session`) and protobuf packages `host.authority.v1`, `host.composition.v1`, `host.installation.v1`, `host.session.v1` under `proto/host/`. Not `codefly.*`: the host's buf configuration excludes a `codefly/` proto directory @@ -505,24 +505,20 @@ gRPC-only composition. An empty HTTP routing table does not remove that endpoint from Codefly's readiness checks. Requests still pass through the authority decision and are refused when no corresponding HTTP upstream exists. -The module behind it carries one interceptor from `libraries/hop/go` and no -other authority code. The stamp binds the digest of the Work Context the hop -checked, so a module that needs the full claims decodes that exact token -without verifying it again. The interceptor gives each call the identity the hop -stamped, and refuses a call that did not come through the hop. It verifies no -token and asks no authority. The stamp is believed only with the run's proof, -which Codefly generates and delivers to the hop and the module alone. In a cell -the mesh plays that part. +The module behind it carries the interceptor from `libraries/hop/go`. It +verifies a short-lived Ed25519 admission bound to the exact method, audience and +token digest, authenticates the identity, and atomically consumes the call nonce +in a durable store shared by replicas. The hop alone receives the Codefly-generated +signing seed; modules receive its public key. See the breaking +[admission protocol and delivery migration](libraries/hop/README.md). The hop and the edge ask the host the composition names in the `authority` group's `endpoint`, the key every module reads for the same host, so pointing that key at saas accounts' Connect listener moves them with the modules: `host:port` and `http://` are the run's plaintext transport (h2c), `https://` is TLS. A composition that names none reaches the authority module's local service. -Under `hop/ENFORCEMENT=warn` the hop lets through a call the authority verified -and the method's policy would have refused, stamped with that refusal as its -advisory; a call with a missing, unsound or misaddressed Work Context, or one -the authority could not be asked about, is refused in either mode. +Signed admissions require enforced method policy in every environment. +Legacy `hop/ENFORCEMENT=warn` configuration is refused with a reason. This run's hop fronts the console, whose one operation answers who the call is for. Run it from the playground workspace, `playground/`, which composes this @@ -530,7 +526,7 @@ repository's modules with the platform's stores and the directory's page: ```bash cd playground -codefly config generate hop PROOF +codefly config generate hop-signing SEED --service authority/hop codefly config generate internal-auth CODEFLY_INTERNAL_TOKEN codefly run service console/api authority/hop --local-agents ``` @@ -595,7 +591,8 @@ resource IDs does not satisfy that requirement, even with a wildcard action; several restricted scopes do not become a whole-kind grant. A whole-kind grant alongside restricted scopes still satisfies its declared action. Refusals mark resource-limited holdings in the diagnostic. `Advise` reports the same refusal -without widening or discarding the checked capability, so warn mode can log it. +without widening or discarding the checked capability. An advisory result is +not sufficient to issue a hop admission. Checking a capability without a method and reading its resource scope remain supported. Modules still own resource visibility, ownership and installation checks; method admission does not replace them. @@ -979,7 +976,7 @@ interface evolution rules require a minor bump below 1.0. Installation Issue requires its installed caller's workload credential; an installation ID alone is never authority. Workloads name the work item's organization; the local sole-installation convenience is not required of other hosts. Advisory Check -is a local development extension, and the hop rejects warn outside local runs. +is a local diagnostic extension; the hop requires enforcing Check in every environment. `codefly.dev/session@0.2.0` adds person and organization display names to Current and SignIn (fields 7 and 8). Missing fields fail conformance. These implement the @@ -1023,6 +1020,30 @@ installation-owned rows contain only attached ids, and an empty owned set emits no grant. The local host is an adapter to that core. Accounts adoption and its durable adapter are not qualified by a local authority test. +`codefly.dev/authority@0.3.2` adds `KeysResponse.admission_keys` and +`PublicKey.purpose`: `Keys` publishes the hop's admission verification keys +(purpose `hop-admission`) beside the Work Context keys (purpose +`work-context`), and every verifier takes the admission keys from the `Keys` +read it already caches and refreshes, so no consumer is configured with a hop +public key. The admission keys are a field of their own, never entries of +`keys`, so a verifier that reads `keys` alone (an earlier release, or the JSON +Web Key Set) can never trust a hop key to sign a Work Context. More than one is +published while a hop key rotates. It is additive, so `>=0.2.0 <0.4.0` +consumers admit it; a host that publishes no admission key leaves every hop +admission refused (`keys_unavailable`), so `accounts` must publish its hop's key +before a cell's modules adopt this verifier. + +`codefly.dev/authority@0.3.3` adds `RegisterAdmissionKey`: the hop presents its +public key under its own workload credential (it declares `module-identity`), +the authority refuses any other workload by name, and `Keys` publishes the key +until its registration lapses (`AdmissionLease`, two minutes) unless the hop +renews it, which it does while it runs. A restarted authority therefore learns +the key again at the next renewal, a restarted hop re-registers the same entry, +and a key no running hop signs with is withdrawn without being named. The seed +never leaves the hop; nothing about the hop is configured on the authority. It +is additive too. The local authority implements it; `accounts` implementing the +same registration is follow-up work in the host. + The 0.3.1 authority version is an additive pre-stable revision selected for this refresh so existing `>=0.2.0 <0.4.0` consumers admit it. It retains 0.3.0's `AuthorizeTransfer` and adds attachment operations. Composition remains 0.2.0. diff --git a/interfaces/authority/interface.codefly.yaml b/interfaces/authority/interface.codefly.yaml index 3e416475..f6c4ccd4 100644 --- a/interfaces/authority/interface.codefly.yaml +++ b/interfaces/authority/interface.codefly.yaml @@ -2,10 +2,10 @@ kind: interface publisher: codefly.dev name: authority description: What a module or solution calls on its host to be issued, checked and verified authority. Implemented by accounts and by the local authority. -version: 0.3.1 +version: 0.3.3 type: connect protobuf: package: host.authority.v1 services: - name: AuthorityService - methods: [Issue, Check, Keys, ListResources, AuthorizeTransfer, AttachResource, DetachResource, Delegate, RevokeDelegation, GetDelegation] + methods: [Issue, Check, Keys, RegisterAdmissionKey, ListResources, AuthorizeTransfer, AttachResource, DetachResource, Delegate, RevokeDelegation, GetDelegation] diff --git a/libraries/authority-client/contract/catalog.codefly.json b/libraries/authority-client/contract/catalog.codefly.json index 0ec26be6..749f8d97 100644 --- a/libraries/authority-client/contract/catalog.codefly.json +++ b/libraries/authority-client/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "api", "path": "contract.binpb", - "digest": "sha256:58c434f9e507929a6cb98a6056464df2cbc27ad486b7c85c88c282d6fc092146", + "digest": "sha256:1c51ec903d141b1df6c0eff0fcaa10aa48b6633e0753a13718bef83d086dc0cb", "services": [ { "name": "LocalService", diff --git a/libraries/authority-client/contract/contract.binpb b/libraries/authority-client/contract/contract.binpb index ca70b22d..64bdfd23 100644 Binary files a/libraries/authority-client/contract/contract.binpb and b/libraries/authority-client/contract/contract.binpb differ diff --git a/libraries/authority-client/go/README.md b/libraries/authority-client/go/README.md index e9d7e757..a1fc844d 100644 --- a/libraries/authority-client/go/README.md +++ b/libraries/authority-client/go/README.md @@ -7,7 +7,7 @@ authenticates the Work Context the hop forwarded and projects its claims: import claims "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" md, _ := metadata.FromIncomingContext(ctx) -c, err := claims.Authenticate(md, proof, audience, trust) +c, err := claims.Authenticate(md, audience, trust) // err wraps claims.ErrUnauthenticated and names the reason. allowed := c.Allows(resourceKind, action, exactResourceID) ``` @@ -17,13 +17,11 @@ A module does not call this directly: `hop.Verifier` runs it on every call. `Authenticate` is the generated-code-free check it rests on, which the shared conformance suite also drives against both hosts. -It checks, in order: this run's hop proof, exactly one token and the stamp's -SHA-256 digest of it (`stamp.Context`); then Core's `Inspector`: canonical -decode, the issuer pin, the Ed25519 signature under the published key the token -names, the audience is this module, not-before and expiry within Core's skew, -and structure and attenuation. It consumes nothing and asks the authority -nothing: liveness is the authority's Check the hop made, bound to this call by -the digest. Proof and digest alone authenticate nothing; the signature does. +It checks one digest-bound token with Core's Inspector: canonical decode, +issuer, signature, audience, window and attenuation. This authenticates identity +only. Method authorization and replay protection require `hop.Verifier` and the +[signed admission protocol](../../hop/README.md). A token digest alone does not +prove that the hop made a live Check. `Scopes()` returns deep copies of the final actor's grants, or the owner's grants when there is no actor. An empty final actor grants nothing. `Allows` evaluates diff --git a/libraries/authority-client/go/gen/host/authority/v1/authority.pb.go b/libraries/authority-client/go/gen/host/authority/v1/authority.pb.go index c0a5c616..16f2a4ae 100644 --- a/libraries/authority-client/go/gen/host/authority/v1/authority.pb.go +++ b/libraries/authority-client/go/gen/host/authority/v1/authority.pb.go @@ -983,8 +983,17 @@ type KeysResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The issuer every capability names. Issuer string `protobuf:"bytes,1,opt,name=issuer,proto3" json:"issuer,omitempty"` - // The public keys by key id. - Keys []*PublicKey `protobuf:"bytes,2,rep,name=keys,proto3" json:"keys,omitempty"` + // The public keys by key id: what a Work Context is signed with, and only + // that. Each carries purpose "work-context". + Keys []*PublicKey `protobuf:"bytes,2,rep,name=keys,proto3" json:"keys,omitempty"` + // The keys a hop signs each call's admission with, each with purpose + // "hop-admission": a module behind the hop verifies the admission by one of + // them. More than one is published while a hop's key is rotated, so the + // retiring and the new key overlap. They are never Work Context keys and are + // kept out of keys, so no verifier that reads keys alone can trust one to + // sign a Work Context. Empty until a hop registers its key + // (RegisterAdmissionKey). + AdmissionKeys []*PublicKey `protobuf:"bytes,3,rep,name=admission_keys,json=admissionKeys,proto3" json:"admission_keys,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1033,6 +1042,13 @@ func (x *KeysResponse) GetKeys() []*PublicKey { return nil } +func (x *KeysResponse) GetAdmissionKeys() []*PublicKey { + if x != nil { + return x.AdmissionKeys + } + return nil +} + // PublicKey is one verification key. type PublicKey struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1041,7 +1057,11 @@ type PublicKey struct { // The signature algorithm; Ed25519. Algorithm string `protobuf:"bytes,2,opt,name=algorithm,proto3" json:"algorithm,omitempty"` // The raw public key bytes. - PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + // What the key verifies: "work-context" (a Work Context) or "hop-admission" + // (a hop's signed call admission). A verifier uses a key only for its own + // purpose. + Purpose string `protobuf:"bytes,4,opt,name=purpose,proto3" json:"purpose,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1097,6 +1117,123 @@ func (x *PublicKey) GetPublicKey() []byte { return nil } +func (x *PublicKey) GetPurpose() string { + if x != nil { + return x.Purpose + } + return "" +} + +// RegisterAdmissionKeyRequest presents one hop admission key. +type RegisterAdmissionKeyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The hop's workload credential; any other workload is refused. + Actor *WorkloadCredential `protobuf:"bytes,1,opt,name=actor,proto3" json:"actor,omitempty"` + // The raw Ed25519 public key the hop signs admissions with: 32 bytes. + PublicKey []byte `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterAdmissionKeyRequest) Reset() { + *x = RegisterAdmissionKeyRequest{} + mi := &file_host_authority_v1_authority_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterAdmissionKeyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterAdmissionKeyRequest) ProtoMessage() {} + +func (x *RegisterAdmissionKeyRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_authority_v1_authority_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterAdmissionKeyRequest.ProtoReflect.Descriptor instead. +func (*RegisterAdmissionKeyRequest) Descriptor() ([]byte, []int) { + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{16} +} + +func (x *RegisterAdmissionKeyRequest) GetActor() *WorkloadCredential { + if x != nil { + return x.Actor + } + return nil +} + +func (x *RegisterAdmissionKeyRequest) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + +// RegisterAdmissionKeyResponse is the key as Keys now publishes it. +type RegisterAdmissionKeyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The published entry, with its key id and purpose "hop-admission". + Key *PublicKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // When the registration lapses unless the hop registers the key again. + ExpiresAtUnix int64 `protobuf:"varint,2,opt,name=expires_at_unix,json=expiresAtUnix,proto3" json:"expires_at_unix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterAdmissionKeyResponse) Reset() { + *x = RegisterAdmissionKeyResponse{} + mi := &file_host_authority_v1_authority_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterAdmissionKeyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterAdmissionKeyResponse) ProtoMessage() {} + +func (x *RegisterAdmissionKeyResponse) ProtoReflect() protoreflect.Message { + mi := &file_host_authority_v1_authority_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterAdmissionKeyResponse.ProtoReflect.Descriptor instead. +func (*RegisterAdmissionKeyResponse) Descriptor() ([]byte, []int) { + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{17} +} + +func (x *RegisterAdmissionKeyResponse) GetKey() *PublicKey { + if x != nil { + return x.Key + } + return nil +} + +func (x *RegisterAdmissionKeyResponse) GetExpiresAtUnix() int64 { + if x != nil { + return x.ExpiresAtUnix + } + return 0 +} + // ListResourcesRequest asks about one kind and one action. type ListResourcesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1114,7 +1251,7 @@ type ListResourcesRequest struct { func (x *ListResourcesRequest) Reset() { *x = ListResourcesRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[16] + mi := &file_host_authority_v1_authority_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1126,7 +1263,7 @@ func (x *ListResourcesRequest) String() string { func (*ListResourcesRequest) ProtoMessage() {} func (x *ListResourcesRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[16] + mi := &file_host_authority_v1_authority_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1139,7 +1276,7 @@ func (x *ListResourcesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListResourcesRequest.ProtoReflect.Descriptor instead. func (*ListResourcesRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{16} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{18} } func (x *ListResourcesRequest) GetToken() string { @@ -1182,7 +1319,7 @@ type ListResourcesResponse struct { func (x *ListResourcesResponse) Reset() { *x = ListResourcesResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[17] + mi := &file_host_authority_v1_authority_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1194,7 +1331,7 @@ func (x *ListResourcesResponse) String() string { func (*ListResourcesResponse) ProtoMessage() {} func (x *ListResourcesResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[17] + mi := &file_host_authority_v1_authority_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1207,7 +1344,7 @@ func (x *ListResourcesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListResourcesResponse.ProtoReflect.Descriptor instead. func (*ListResourcesResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{17} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{19} } func (x *ListResourcesResponse) GetGrants() []*ResourceGrant { @@ -1234,7 +1371,7 @@ type AttachResourceRequest struct { func (x *AttachResourceRequest) Reset() { *x = AttachResourceRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1246,7 +1383,7 @@ func (x *AttachResourceRequest) String() string { func (*AttachResourceRequest) ProtoMessage() {} func (x *AttachResourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1259,7 +1396,7 @@ func (x *AttachResourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachResourceRequest.ProtoReflect.Descriptor instead. func (*AttachResourceRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{18} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{20} } func (x *AttachResourceRequest) GetToken() string { @@ -1301,7 +1438,7 @@ type AttachResourceResponse struct { func (x *AttachResourceResponse) Reset() { *x = AttachResourceResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[19] + mi := &file_host_authority_v1_authority_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1313,7 +1450,7 @@ func (x *AttachResourceResponse) String() string { func (*AttachResourceResponse) ProtoMessage() {} func (x *AttachResourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[19] + mi := &file_host_authority_v1_authority_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1326,7 +1463,7 @@ func (x *AttachResourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachResourceResponse.ProtoReflect.Descriptor instead. func (*AttachResourceResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{19} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{21} } func (x *AttachResourceResponse) GetInstallationId() string { @@ -1353,7 +1490,7 @@ type DetachResourceRequest struct { func (x *DetachResourceRequest) Reset() { *x = DetachResourceRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[20] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1365,7 +1502,7 @@ func (x *DetachResourceRequest) String() string { func (*DetachResourceRequest) ProtoMessage() {} func (x *DetachResourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[20] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1378,7 +1515,7 @@ func (x *DetachResourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DetachResourceRequest.ProtoReflect.Descriptor instead. func (*DetachResourceRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{20} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{22} } func (x *DetachResourceRequest) GetActor() *WorkloadCredential { @@ -1420,7 +1557,7 @@ type DetachResourceResponse struct { func (x *DetachResourceResponse) Reset() { *x = DetachResourceResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[21] + mi := &file_host_authority_v1_authority_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1432,7 +1569,7 @@ func (x *DetachResourceResponse) String() string { func (*DetachResourceResponse) ProtoMessage() {} func (x *DetachResourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[21] + mi := &file_host_authority_v1_authority_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1445,7 +1582,7 @@ func (x *DetachResourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DetachResourceResponse.ProtoReflect.Descriptor instead. func (*DetachResourceResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{21} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{23} } func (x *DetachResourceResponse) GetInstallationId() string { @@ -1477,7 +1614,7 @@ type AuthorizeTransferRequest struct { func (x *AuthorizeTransferRequest) Reset() { *x = AuthorizeTransferRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[22] + mi := &file_host_authority_v1_authority_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1489,7 +1626,7 @@ func (x *AuthorizeTransferRequest) String() string { func (*AuthorizeTransferRequest) ProtoMessage() {} func (x *AuthorizeTransferRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[22] + mi := &file_host_authority_v1_authority_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1502,7 +1639,7 @@ func (x *AuthorizeTransferRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizeTransferRequest.ProtoReflect.Descriptor instead. func (*AuthorizeTransferRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{22} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{24} } func (x *AuthorizeTransferRequest) GetToken() string { @@ -1560,7 +1697,7 @@ type AuthorizeTransferResponse struct { func (x *AuthorizeTransferResponse) Reset() { *x = AuthorizeTransferResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[23] + mi := &file_host_authority_v1_authority_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1572,7 +1709,7 @@ func (x *AuthorizeTransferResponse) String() string { func (*AuthorizeTransferResponse) ProtoMessage() {} func (x *AuthorizeTransferResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[23] + mi := &file_host_authority_v1_authority_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1585,7 +1722,7 @@ func (x *AuthorizeTransferResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizeTransferResponse.ProtoReflect.Descriptor instead. func (*AuthorizeTransferResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{23} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{25} } func (x *AuthorizeTransferResponse) GetPrincipalId() string { @@ -1622,7 +1759,7 @@ type ResourceGrant struct { func (x *ResourceGrant) Reset() { *x = ResourceGrant{} - mi := &file_host_authority_v1_authority_proto_msgTypes[24] + mi := &file_host_authority_v1_authority_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1634,7 +1771,7 @@ func (x *ResourceGrant) String() string { func (*ResourceGrant) ProtoMessage() {} func (x *ResourceGrant) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[24] + mi := &file_host_authority_v1_authority_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1647,7 +1784,7 @@ func (x *ResourceGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceGrant.ProtoReflect.Descriptor instead. func (*ResourceGrant) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{24} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{26} } func (x *ResourceGrant) GetMatch() isResourceGrant_Match { @@ -1801,15 +1938,24 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\acontext\x18\x01 \x01(\v2 .host.authority.v1.WorkContextV1R\acontext\x125\n" + "\x16authorization_revision\x18\x02 \x01(\x04R\x15authorizationRevision\x12\x18\n" + "\arefusal\x18\x03 \x01(\tR\arefusal\"\r\n" + - "\vKeysRequest\"X\n" + + "\vKeysRequest\"\x9d\x01\n" + "\fKeysResponse\x12\x16\n" + "\x06issuer\x18\x01 \x01(\tR\x06issuer\x120\n" + - "\x04keys\x18\x02 \x03(\v2\x1c.host.authority.v1.PublicKeyR\x04keys\"_\n" + + "\x04keys\x18\x02 \x03(\v2\x1c.host.authority.v1.PublicKeyR\x04keys\x12C\n" + + "\x0eadmission_keys\x18\x03 \x03(\v2\x1c.host.authority.v1.PublicKeyR\radmissionKeys\"y\n" + "\tPublicKey\x12\x15\n" + "\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1c\n" + "\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n" + "\n" + - "public_key\x18\x03 \x01(\fR\tpublicKey\"\x85\x01\n" + + "public_key\x18\x03 \x01(\fR\tpublicKey\x12\x18\n" + + "\apurpose\x18\x04 \x01(\tR\apurpose\"y\n" + + "\x1bRegisterAdmissionKeyRequest\x12;\n" + + "\x05actor\x18\x01 \x01(\v2%.host.authority.v1.WorkloadCredentialR\x05actor\x12\x1d\n" + + "\n" + + "public_key\x18\x02 \x01(\fR\tpublicKey\"v\n" + + "\x1cRegisterAdmissionKeyResponse\x12.\n" + + "\x03key\x18\x01 \x01(\v2\x1c.host.authority.v1.PublicKeyR\x03key\x12&\n" + + "\x0fexpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\"\x85\x01\n" + "\x14ListResourcesRequest\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n" + "\baudience\x18\x02 \x01(\tR\baudience\x12#\n" + @@ -1850,11 +1996,12 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n" + "\apattern\x18\x04 \x01(\tH\x00R\apattern\x12*\n" + "\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\a\n" + - "\x05match2\xea\a\n" + + "\x05match2\xe3\b\n" + "\x10AuthorityService\x12j\n" + "\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/v1/authority:issue\x12j\n" + "\x05Check\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/v1/authority:check\x12G\n" + - "\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12b\n" + + "\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12w\n" + + "\x14RegisterAdmissionKey\x12..host.authority.v1.RegisterAdmissionKeyRequest\x1a/.host.authority.v1.RegisterAdmissionKeyResponse\x12b\n" + "\rListResources\x12'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12e\n" + "\x0eAttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12e\n" + "\x0eDetachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n" + @@ -1876,79 +2023,86 @@ func file_host_authority_v1_authority_proto_rawDescGZIP() []byte { return file_host_authority_v1_authority_proto_rawDescData } -var file_host_authority_v1_authority_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_host_authority_v1_authority_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_host_authority_v1_authority_proto_goTypes = []any{ - (*IssueRequest)(nil), // 0: host.authority.v1.IssueRequest - (*DelegationRef)(nil), // 1: host.authority.v1.DelegationRef - (*DelegateRequest)(nil), // 2: host.authority.v1.DelegateRequest - (*Delegation)(nil), // 3: host.authority.v1.Delegation - (*GetDelegationRequest)(nil), // 4: host.authority.v1.GetDelegationRequest - (*RevokeDelegationRequest)(nil), // 5: host.authority.v1.RevokeDelegationRequest - (*RevokeDelegationResponse)(nil), // 6: host.authority.v1.RevokeDelegationResponse - (*PersonSession)(nil), // 7: host.authority.v1.PersonSession - (*WorkloadCredential)(nil), // 8: host.authority.v1.WorkloadCredential - (*InstallationRef)(nil), // 9: host.authority.v1.InstallationRef - (*IssueResponse)(nil), // 10: host.authority.v1.IssueResponse - (*CheckRequest)(nil), // 11: host.authority.v1.CheckRequest - (*CheckResponse)(nil), // 12: host.authority.v1.CheckResponse - (*KeysRequest)(nil), // 13: host.authority.v1.KeysRequest - (*KeysResponse)(nil), // 14: host.authority.v1.KeysResponse - (*PublicKey)(nil), // 15: host.authority.v1.PublicKey - (*ListResourcesRequest)(nil), // 16: host.authority.v1.ListResourcesRequest - (*ListResourcesResponse)(nil), // 17: host.authority.v1.ListResourcesResponse - (*AttachResourceRequest)(nil), // 18: host.authority.v1.AttachResourceRequest - (*AttachResourceResponse)(nil), // 19: host.authority.v1.AttachResourceResponse - (*DetachResourceRequest)(nil), // 20: host.authority.v1.DetachResourceRequest - (*DetachResourceResponse)(nil), // 21: host.authority.v1.DetachResourceResponse - (*AuthorizeTransferRequest)(nil), // 22: host.authority.v1.AuthorizeTransferRequest - (*AuthorizeTransferResponse)(nil), // 23: host.authority.v1.AuthorizeTransferResponse - (*ResourceGrant)(nil), // 24: host.authority.v1.ResourceGrant - (*WorkScopeV1)(nil), // 25: host.authority.v1.WorkScopeV1 - (*WorkContextV1)(nil), // 26: host.authority.v1.WorkContextV1 + (*IssueRequest)(nil), // 0: host.authority.v1.IssueRequest + (*DelegationRef)(nil), // 1: host.authority.v1.DelegationRef + (*DelegateRequest)(nil), // 2: host.authority.v1.DelegateRequest + (*Delegation)(nil), // 3: host.authority.v1.Delegation + (*GetDelegationRequest)(nil), // 4: host.authority.v1.GetDelegationRequest + (*RevokeDelegationRequest)(nil), // 5: host.authority.v1.RevokeDelegationRequest + (*RevokeDelegationResponse)(nil), // 6: host.authority.v1.RevokeDelegationResponse + (*PersonSession)(nil), // 7: host.authority.v1.PersonSession + (*WorkloadCredential)(nil), // 8: host.authority.v1.WorkloadCredential + (*InstallationRef)(nil), // 9: host.authority.v1.InstallationRef + (*IssueResponse)(nil), // 10: host.authority.v1.IssueResponse + (*CheckRequest)(nil), // 11: host.authority.v1.CheckRequest + (*CheckResponse)(nil), // 12: host.authority.v1.CheckResponse + (*KeysRequest)(nil), // 13: host.authority.v1.KeysRequest + (*KeysResponse)(nil), // 14: host.authority.v1.KeysResponse + (*PublicKey)(nil), // 15: host.authority.v1.PublicKey + (*RegisterAdmissionKeyRequest)(nil), // 16: host.authority.v1.RegisterAdmissionKeyRequest + (*RegisterAdmissionKeyResponse)(nil), // 17: host.authority.v1.RegisterAdmissionKeyResponse + (*ListResourcesRequest)(nil), // 18: host.authority.v1.ListResourcesRequest + (*ListResourcesResponse)(nil), // 19: host.authority.v1.ListResourcesResponse + (*AttachResourceRequest)(nil), // 20: host.authority.v1.AttachResourceRequest + (*AttachResourceResponse)(nil), // 21: host.authority.v1.AttachResourceResponse + (*DetachResourceRequest)(nil), // 22: host.authority.v1.DetachResourceRequest + (*DetachResourceResponse)(nil), // 23: host.authority.v1.DetachResourceResponse + (*AuthorizeTransferRequest)(nil), // 24: host.authority.v1.AuthorizeTransferRequest + (*AuthorizeTransferResponse)(nil), // 25: host.authority.v1.AuthorizeTransferResponse + (*ResourceGrant)(nil), // 26: host.authority.v1.ResourceGrant + (*WorkScopeV1)(nil), // 27: host.authority.v1.WorkScopeV1 + (*WorkContextV1)(nil), // 28: host.authority.v1.WorkContextV1 } var file_host_authority_v1_authority_proto_depIdxs = []int32{ 7, // 0: host.authority.v1.IssueRequest.person:type_name -> host.authority.v1.PersonSession 8, // 1: host.authority.v1.IssueRequest.workload:type_name -> host.authority.v1.WorkloadCredential 9, // 2: host.authority.v1.IssueRequest.installation:type_name -> host.authority.v1.InstallationRef 1, // 3: host.authority.v1.IssueRequest.delegation:type_name -> host.authority.v1.DelegationRef - 25, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 27, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 5: host.authority.v1.IssueRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 25, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 - 25, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 + 27, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 27, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 8: host.authority.v1.GetDelegationRequest.actor:type_name -> host.authority.v1.WorkloadCredential 3, // 9: host.authority.v1.RevokeDelegationResponse.delegation:type_name -> host.authority.v1.Delegation - 26, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 - 26, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 + 28, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 + 28, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 15, // 12: host.authority.v1.KeysResponse.keys:type_name -> host.authority.v1.PublicKey - 24, // 13: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant - 8, // 14: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 8, // 15: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 8, // 16: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 0, // 17: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest - 11, // 18: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest - 13, // 19: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest - 16, // 20: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest - 18, // 21: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest - 20, // 22: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest - 22, // 23: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest - 2, // 24: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest - 5, // 25: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest - 4, // 26: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest - 10, // 27: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse - 12, // 28: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse - 14, // 29: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse - 17, // 30: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse - 19, // 31: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse - 21, // 32: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse - 23, // 33: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse - 3, // 34: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation - 6, // 35: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse - 3, // 36: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation - 27, // [27:37] is the sub-list for method output_type - 17, // [17:27] is the sub-list for method input_type - 17, // [17:17] is the sub-list for extension type_name - 17, // [17:17] is the sub-list for extension extendee - 0, // [0:17] is the sub-list for field type_name + 15, // 13: host.authority.v1.KeysResponse.admission_keys:type_name -> host.authority.v1.PublicKey + 8, // 14: host.authority.v1.RegisterAdmissionKeyRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 15, // 15: host.authority.v1.RegisterAdmissionKeyResponse.key:type_name -> host.authority.v1.PublicKey + 26, // 16: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant + 8, // 17: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 18: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 19: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 0, // 20: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest + 11, // 21: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest + 13, // 22: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest + 16, // 23: host.authority.v1.AuthorityService.RegisterAdmissionKey:input_type -> host.authority.v1.RegisterAdmissionKeyRequest + 18, // 24: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest + 20, // 25: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest + 22, // 26: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest + 24, // 27: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest + 2, // 28: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest + 5, // 29: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest + 4, // 30: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest + 10, // 31: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse + 12, // 32: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse + 14, // 33: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse + 17, // 34: host.authority.v1.AuthorityService.RegisterAdmissionKey:output_type -> host.authority.v1.RegisterAdmissionKeyResponse + 19, // 35: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse + 21, // 36: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse + 23, // 37: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse + 25, // 38: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse + 3, // 39: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation + 6, // 40: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse + 3, // 41: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation + 31, // [31:42] is the sub-list for method output_type + 20, // [20:31] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_host_authority_v1_authority_proto_init() } @@ -1964,7 +2118,7 @@ func file_host_authority_v1_authority_proto_init() { (*IssueRequest_ParentToken)(nil), (*IssueRequest_Delegation)(nil), } - file_host_authority_v1_authority_proto_msgTypes[24].OneofWrappers = []any{ + file_host_authority_v1_authority_proto_msgTypes[26].OneofWrappers = []any{ (*ResourceGrant_All)(nil), (*ResourceGrant_Id)(nil), (*ResourceGrant_Prefix)(nil), @@ -1976,7 +2130,7 @@ func file_host_authority_v1_authority_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_host_authority_v1_authority_proto_rawDesc), len(file_host_authority_v1_authority_proto_rawDesc)), NumEnums: 0, - NumMessages: 25, + NumMessages: 27, NumExtensions: 0, NumServices: 1, }, diff --git a/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go b/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go index c2e63df5..02afed32 100644 --- a/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go +++ b/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go @@ -26,16 +26,17 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - AuthorityService_Issue_FullMethodName = "/host.authority.v1.AuthorityService/Issue" - AuthorityService_Check_FullMethodName = "/host.authority.v1.AuthorityService/Check" - AuthorityService_Keys_FullMethodName = "/host.authority.v1.AuthorityService/Keys" - AuthorityService_ListResources_FullMethodName = "/host.authority.v1.AuthorityService/ListResources" - AuthorityService_AttachResource_FullMethodName = "/host.authority.v1.AuthorityService/AttachResource" - AuthorityService_DetachResource_FullMethodName = "/host.authority.v1.AuthorityService/DetachResource" - AuthorityService_AuthorizeTransfer_FullMethodName = "/host.authority.v1.AuthorityService/AuthorizeTransfer" - AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" - AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" - AuthorityService_GetDelegation_FullMethodName = "/host.authority.v1.AuthorityService/GetDelegation" + AuthorityService_Issue_FullMethodName = "/host.authority.v1.AuthorityService/Issue" + AuthorityService_Check_FullMethodName = "/host.authority.v1.AuthorityService/Check" + AuthorityService_Keys_FullMethodName = "/host.authority.v1.AuthorityService/Keys" + AuthorityService_RegisterAdmissionKey_FullMethodName = "/host.authority.v1.AuthorityService/RegisterAdmissionKey" + AuthorityService_ListResources_FullMethodName = "/host.authority.v1.AuthorityService/ListResources" + AuthorityService_AttachResource_FullMethodName = "/host.authority.v1.AuthorityService/AttachResource" + AuthorityService_DetachResource_FullMethodName = "/host.authority.v1.AuthorityService/DetachResource" + AuthorityService_AuthorizeTransfer_FullMethodName = "/host.authority.v1.AuthorityService/AuthorizeTransfer" + AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" + AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" + AuthorityService_GetDelegation_FullMethodName = "/host.authority.v1.AuthorityService/GetDelegation" ) // AuthorityServiceClient is the client API for AuthorityService service. @@ -53,8 +54,21 @@ type AuthorityServiceClient interface { // Check verifies a presented Work Context for the audience the caller is, // re-checking the authorization revision and the seal live. Check(ctx context.Context, in *CheckRequest, opts ...grpc.CallOption) (*CheckResponse, error) - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + RegisterAdmissionKey(ctx context.Context, in *RegisterAdmissionKeyRequest, opts ...grpc.CallOption) (*RegisterAdmissionKeyResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -159,6 +173,16 @@ func (c *authorityServiceClient) Keys(ctx context.Context, in *KeysRequest, opts return out, nil } +func (c *authorityServiceClient) RegisterAdmissionKey(ctx context.Context, in *RegisterAdmissionKeyRequest, opts ...grpc.CallOption) (*RegisterAdmissionKeyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RegisterAdmissionKeyResponse) + err := c.cc.Invoke(ctx, AuthorityService_RegisterAdmissionKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *authorityServiceClient) ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListResourcesResponse) @@ -244,8 +268,21 @@ type AuthorityServiceServer interface { // Check verifies a presented Work Context for the audience the caller is, // re-checking the authorization revision and the seal live. Check(context.Context, *CheckRequest) (*CheckResponse, error) - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. Keys(context.Context, *KeysRequest) (*KeysResponse, error) + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + RegisterAdmissionKey(context.Context, *RegisterAdmissionKeyRequest) (*RegisterAdmissionKeyResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -329,6 +366,9 @@ func (UnimplementedAuthorityServiceServer) Check(context.Context, *CheckRequest) func (UnimplementedAuthorityServiceServer) Keys(context.Context, *KeysRequest) (*KeysResponse, error) { return nil, status.Error(codes.Unimplemented, "method Keys not implemented") } +func (UnimplementedAuthorityServiceServer) RegisterAdmissionKey(context.Context, *RegisterAdmissionKeyRequest) (*RegisterAdmissionKeyResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RegisterAdmissionKey not implemented") +} func (UnimplementedAuthorityServiceServer) ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListResources not implemented") } @@ -425,6 +465,24 @@ func _AuthorityService_Keys_Handler(srv interface{}, ctx context.Context, dec fu return interceptor(ctx, in, info, handler) } +func _AuthorityService_RegisterAdmissionKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterAdmissionKeyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorityServiceServer).RegisterAdmissionKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorityService_RegisterAdmissionKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorityServiceServer).RegisterAdmissionKey(ctx, req.(*RegisterAdmissionKeyRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AuthorityService_ListResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ListResourcesRequest) if err := dec(in); err != nil { @@ -570,6 +628,10 @@ var AuthorityService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Keys", Handler: _AuthorityService_Keys_Handler, }, + { + MethodName: "RegisterAdmissionKey", + Handler: _AuthorityService_RegisterAdmissionKey_Handler, + }, { MethodName: "ListResources", Handler: _AuthorityService_ListResources_Handler, diff --git a/libraries/authority-client/go/refusal/reason.go b/libraries/authority-client/go/refusal/reason.go index dc86801f..d958bd34 100644 --- a/libraries/authority-client/go/refusal/reason.go +++ b/libraries/authority-client/go/refusal/reason.go @@ -11,6 +11,13 @@ const ( StampToken Reason = "stamp_token" StampDigest Reason = "stamp_digest" StampIdentity Reason = "stamp_identity" + StampMethod Reason = "stamp_method" + StampAudience Reason = "stamp_audience" + StampWindow Reason = "stamp_window" + StampIssued Reason = "stamp_issued" + StampAdvisory Reason = "stamp_advisory" + StampReplayed Reason = "stamp_replayed" + StampStore Reason = "stamp_store" KeysUnavailable Reason = "keys_unavailable" Cancelled Reason = "cancelled" Malformed Reason = "malformed" @@ -34,13 +41,27 @@ func (r Reason) Text() string { case Configuration: return "the verifier configuration is incomplete" case StampProof: - return "the call requires this run's hop proof" + return "the call carries no hop admission signed for it" case StampToken: return "the stamp binds no single Work Context" case StampDigest: return "the Work Context does not match the hop stamp" case StampIdentity: return "the stamp does not match the authenticated Work Context" + case StampMethod: + return "the hop admission was signed for another procedure" + case StampAudience: + return "the hop admission was signed for another module" + case StampWindow: + return "the hop admission is expired or outside its validity window" + case StampIssued: + return "the hop admission is issued further ahead than the clock allowance" + case StampAdvisory: + return "an advisory policy refusal cannot admit a call" + case StampReplayed: + return "the hop admission has already been used" + case StampStore: + return "the hop admission store is unavailable" case KeysUnavailable: return "the authority's published keys are unavailable" case Cancelled: diff --git a/libraries/authority-client/go/stamp/call.go b/libraries/authority-client/go/stamp/call.go new file mode 100644 index 00000000..9543d6d3 --- /dev/null +++ b/libraries/authority-client/go/stamp/call.go @@ -0,0 +1,222 @@ +package stamp + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + "google.golang.org/grpc/metadata" +) + +// Lifetime bounds how long an admitted call may start. It does not limit a stream's duration. +const Lifetime = 30 * time.Second + +// Skew is the clock difference tolerated between the hop that signed an +// admission and the verifier judging it. Without it a verifier lagging the hop +// by a fraction of a second refuses genuine calls whenever the signing instant +// falls just after a second boundary, and the refusal reads as an expiry. +// +// It widens two bounds and no others: an admission may be issued up to Skew +// ahead of the verifier's clock, and remains admissible until Skew past its +// expiry. The signed span itself (expires - issued) is still capped at Lifetime +// exactly, so the allowance cannot be used to mint a longer-lived admission. +// Go, Python and TypeScript apply the same value, and a replay reservation is +// retained to expiry plus Skew so a store evicting on its own clock cannot +// release a nonce a lagging verifier would still accept. +const Skew = 2 * time.Second + +// Call is a signed, single-use admission, independent of the Work Context's replay policy. +// The array encoding and domain separate this protocol from Work Context signatures. +type Call struct { + Method string + Audience string + Digest string + Nonce string + Issued int64 + Expires int64 +} + +// Signer holds a service-scoped seed generated by Codefly. Never deliver it to a verifier. +type Signer struct{ key ed25519.PrivateKey } + +func NewSigner(seed string) (*Signer, error) { + raw, err := hex.DecodeString(seed) + if err != nil || len(raw) != ed25519.SeedSize { + return nil, errors.New("hop: signing seed must be 32 Codefly-generated bytes in hex") + } + return &Signer{key: ed25519.NewKeyFromSeed(raw)}, nil +} + +func (s *Signer) PublicKey() string { return hex.EncodeToString(s.key.Public().(ed25519.PublicKey)) } + +// Sign is called only after the authority admits the exact method and audience. +func (s *Signer) Sign(token, method, audience string, now time.Time, tokenExpiry int64) (string, error) { + if s == nil || len(s.key) != ed25519.PrivateKeySize || token == "" || method == "" || audience == "" { + return "", errors.New("hop: signing needs a token, exact method, audience and signing key") + } + expires := now.Add(Lifetime).Unix() + if tokenExpiry < expires { + expires = tokenExpiry + } + if expires <= now.Unix() { + return "", errors.New("hop: cannot stamp an expired context") + } + nonce := make([]byte, 32) + if _, err := rand.Read(nonce); err != nil { + return "", errors.New("hop: cannot generate call nonce") + } + digest := sha256.Sum256([]byte(token)) + payload, err := json.Marshal([]any{"codefly-hop-v1", method, audience, hex.EncodeToString(digest[:]), hex.EncodeToString(nonce), now.Unix(), expires}) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(s.key, payload)), nil +} + +// Check authenticates the binding before any keys lookup or replay-store write. +// publicKey is one of the authority's published admission keys (SignedBy +// chooses it), never read from incoming metadata. +// Every refusal is a caller-safe refusal.Reason: it names no proof, token, +// digest or nonce, and callers read its code with errors.As. +func Check(md metadata.MD, publicKey, method, audience string, now time.Time) (Call, error) { + fail := func(reason refusal.Reason) (Call, error) { return Call{}, reason } + one := func(key string) string { + v := md.Get(key) + if len(v) != 1 { + return "" + } + return v[0] + } + payload, signature, ok := proof(md) + pub, err := hex.DecodeString(publicKey) + if !ok || err != nil || len(pub) != ed25519.PublicKeySize || !ed25519.Verify(pub, payload, signature) { + return fail(refusal.StampProof) + } + var fields []json.RawMessage + if json.Unmarshal(payload, &fields) != nil || len(fields) != 7 { + return fail(refusal.StampProof) + } + var domain string + var call Call + for n, dst := range []any{&domain, &call.Method, &call.Audience, &call.Digest, &call.Nonce, &call.Issued, &call.Expires} { + if string(fields[n]) == "null" || json.Unmarshal(fields[n], dst) != nil { + return fail(refusal.StampProof) + } + } + if domain != "codefly-hop-v1" { + return fail(refusal.StampProof) + } + if method == "" || call.Method != method { + return fail(refusal.StampMethod) + } + if audience == "" || call.Audience != audience { + return fail(refusal.StampAudience) + } + seconds, skew := now.Unix(), int64(Skew/time.Second) + switch { + case call.Issued < 0 || call.Expires < 0: + return fail(refusal.StampWindow) + case call.Issued > seconds+skew: + // Further ahead than a clock difference explains: its own reason, so a + // verifier running behind is not diagnosed as an expiry. + return fail(refusal.StampIssued) + case call.Expires+skew <= seconds: + return fail(refusal.StampWindow) + case call.Expires <= call.Issued || call.Expires-call.Issued > int64(Lifetime/time.Second): + // The signed span is judged without the allowance. + return fail(refusal.StampWindow) + } + nonce, err := hex.DecodeString(call.Nonce) + if err != nil || len(nonce) != 32 || hex.EncodeToString(nonce) != call.Nonce { + return fail(refusal.StampProof) + } + // A missing or repeated token is the stamp binding nothing; a token that + // does not hash to the signed digest, or a context key disagreeing with + // it, is a swap. The two are distinct refusals. + tokens := md.Get("x-codefly-work-context") + if len(tokens) != 1 || tokens[0] == "" { + return fail(refusal.StampToken) + } + digest := sha256.Sum256([]byte(tokens[0])) + if call.Digest != hex.EncodeToString(digest[:]) || one("x-codefly-hop-context") != call.Digest { + return fail(refusal.StampDigest) + } + if len(md.Get("x-codefly-hop-advisory")) != 0 { + return fail(refusal.StampAdvisory) + } + return call, nil +} + +// proof is the one admission md carries, split into its signed payload and +// signature, each in canonical encoding; ok is false for anything else. +func proof(md metadata.MD) (payload, signature []byte, ok bool) { + values := md.Get("x-codefly-hop-proof") + if len(values) != 1 || len(values[0]) > 8192 { + return nil, nil, false + } + parts := strings.Split(values[0], ".") + if len(parts) != 2 { + return nil, nil, false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil || base64.RawURLEncoding.EncodeToString(payload) != parts[0] { + return nil, nil, false + } + signature, err = base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || base64.RawURLEncoding.EncodeToString(signature) != parts[1] { + return nil, nil, false + } + return payload, signature, true +} + +// SignedBy is which of the published admission keys (hex) the admission md +// carries is signed by, or "" when it is signed by none of them. It +// authenticates only the signature: Check, given the key it names, judges the +// rest. A verifier trusts more than one key while a hop's key rotates. +func SignedBy(md metadata.MD, publicKeys []string) string { + payload, signature, ok := proof(md) + if !ok { + return "" + } + for _, key := range publicKeys { + pub, err := hex.DecodeString(key) + if err == nil && len(pub) == ed25519.PublicKeySize && ed25519.Verify(pub, payload, signature) { + return key + } + } + return "" +} + +// Purpose is what a published admission key verifies: Keys carries the hop's +// keys in admission_keys with this purpose, never among the Work Context keys. +const Purpose = "hop-admission" + +// Admission is the signer's public key as the authority publishes it in Keys' +// admission_keys, with algorithm Ed25519 and purpose Purpose: a key id derived +// from the key, so the same seed always publishes the same entry, and the raw +// public key. It names no generated type, so a host with bindings of its own +// builds the entry in them. +func (s *Signer) Admission() (keyID string, public []byte) { + key := s.key.Public().(ed25519.PublicKey) + return KeyID(key), []byte(key) +} + +// KeyID is the key id an admission key is published under, derived from the +// key alone: the hop and the authority it registers with name it alike. +func KeyID(public ed25519.PublicKey) string { + sum := sha256.Sum256(public) + return "hop-" + hex.EncodeToString(sum[:8]) +} + +// ReplayKey is globally namespaced by signing key and random nonce, not by method: +// even an accidental nonce reuse on another method must not authorize another call. +func (c Call) ReplayKey(publicKey string) string { + return "codefly-hop-v1:" + publicKey + ":" + c.Nonce +} diff --git a/libraries/authority-client/go/stamp/call_test.go b/libraries/authority-client/go/stamp/call_test.go new file mode 100644 index 00000000..87ddc44f --- /dev/null +++ b/libraries/authority-client/go/stamp/call_test.go @@ -0,0 +1,129 @@ +package stamp + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + "google.golang.org/grpc/metadata" +) + +func TestSignedCallBindingRejectsMutationAndExpiredOrAmbiguousProof(t *testing.T) { + signer, err := NewSigner(strings.Repeat("01", 32)) + if err != nil { + t.Fatal(err) + } + now := time.Unix(1700000000, 0) + const token = "synthetic-context" + proof, err := signer.Sign(token, "/pkg.Service/Read", "module", now, now.Add(time.Minute).Unix()) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte(token)) + md := metadata.Pairs("x-codefly-hop-proof", proof, "x-codefly-work-context", token, "x-codefly-hop-context", hex.EncodeToString(digest[:])) + if _, err := Check(md, signer.PublicKey(), "/pkg.Service/Read", "module", now); err != nil { + t.Fatal(err) + } + for _, mutation := range []func(metadata.MD){ + func(m metadata.MD) { m.Set("x-codefly-hop-proof", proof[:len(proof)-2]+"AA") }, + func(m metadata.MD) { m.Append("x-codefly-hop-proof", proof) }, + func(m metadata.MD) { m.Append("x-codefly-work-context", token) }, + func(m metadata.MD) { m.Append("x-codefly-hop-context", hex.EncodeToString(digest[:])) }, + func(m metadata.MD) { m.Set("x-codefly-hop-advisory", "refused") }, + } { + changed := md.Copy() + mutation(changed) + if _, err := Check(changed, signer.PublicKey(), "/pkg.Service/Read", "module", now); err == nil { + t.Fatal("mutated proof accepted") + } + } + // Inside the clock allowance, a verifier running behind or a little past the + // expiry still admits the call: these are clock differences, not forgeries. + for _, at := range []time.Time{now.Add(-Skew), now.Add(Lifetime), now.Add(Lifetime + Skew - time.Second)} { + if _, err := Check(md, signer.PublicKey(), "/pkg.Service/Read", "module", at); err != nil { + t.Fatalf("a clock difference of %v inside the allowance was refused: %v", at.Sub(now), err) + } + } + // Beyond it, each bound is refused under its own reason. + for _, tc := range []struct { + at time.Time + want refusal.Reason + }{ + {now.Add(-Skew - time.Second), refusal.StampIssued}, + {now.Add(Lifetime + Skew), refusal.StampWindow}, + } { + _, err := Check(md, signer.PublicKey(), "/pkg.Service/Read", "module", tc.at) + var reason refusal.Reason + if !errors.As(err, &reason) || reason != tc.want { + t.Fatalf("at %v want %s: %v", tc.at.Sub(now), tc.want, err) + } + } + // The allowance widens no span: a signed span longer than Lifetime is + // refused however the clocks sit. + long, err := signer.Sign(token, "/pkg.Service/Read", "module", now, now.Add(time.Hour).Unix()) + if err != nil { + t.Fatal(err) + } + wide := md.Copy() + wide.Set("x-codefly-hop-proof", long) + if call, err := Check(wide, signer.PublicKey(), "/pkg.Service/Read", "module", now); err != nil { + t.Fatalf("a token outliving the admission caps the span, not refuses it: %v", err) + } else if call.Expires-call.Issued != int64(Lifetime/time.Second) { + t.Fatalf("span %ds, want %ds", call.Expires-call.Issued, int64(Lifetime/time.Second)) + } + if _, err := signer.Sign(token, "/pkg.Service/Read", "module", now, now.Unix()); err == nil { + t.Fatal("signed expired capability") + } +} + +// A verifier trusts every published admission key while a hop's key rotates: +// SignedBy names the one a proof is signed by, and none for a key nobody +// published or a proof that is not one. +func TestSignedByNamesThePublishedKeyAProofIsSignedBy(t *testing.T) { + retiring, _ := NewSigner(strings.Repeat("01", 32)) + current, _ := NewSigner(strings.Repeat("02", 32)) + unpublished, _ := NewSigner(strings.Repeat("03", 32)) + now := time.Unix(1700000000, 0) + published := []string{retiring.PublicKey(), current.PublicKey()} + for _, tc := range []struct { + signer *Signer + want string + }{{retiring, retiring.PublicKey()}, {current, current.PublicKey()}, {unpublished, ""}} { + proof, err := tc.signer.Sign("synthetic-context", "/pkg.Service/Read", "module", now, now.Add(time.Minute).Unix()) + if err != nil { + t.Fatal(err) + } + if got := SignedBy(metadata.Pairs("x-codefly-hop-proof", proof), published); got != tc.want { + t.Fatalf("signed by %q, want %q", got, tc.want) + } + if got := SignedBy(metadata.Pairs("x-codefly-hop-proof", proof, "x-codefly-hop-proof", proof), published); got != "" { + t.Fatalf("a repeated proof named %q", got) + } + } + if SignedBy(metadata.Pairs("x-codefly-hop-proof", "not.a-proof"), published) != "" || SignedBy(metadata.MD{}, published) != "" { + t.Fatal("a malformed or missing proof named a key") + } +} + +// The authority publishes a seed's public half as one stable entry: the same +// seed always yields the same key id beside its public key, and the authority +// a hop registers the key with derives that id from the key alone (KeyID). +func TestAdmissionIsTheSignersStablePublishedEntry(t *testing.T) { + signer, _ := NewSigner(strings.Repeat("01", 32)) + again, _ := NewSigner(strings.Repeat("01", 32)) + other, _ := NewSigner(strings.Repeat("02", 32)) + id, public := signer.Admission() + if hex.EncodeToString(public) != signer.PublicKey() || id == "" || KeyID(public) != id { + t.Fatalf("entry %q %x", id, public) + } + if againID, _ := again.Admission(); againID != id { + t.Fatalf("key id %q is not stable", id) + } + if otherID, _ := other.Admission(); otherID == id { + t.Fatal("two keys share a key id") + } +} diff --git a/libraries/authority-client/go/stamp/context.go b/libraries/authority-client/go/stamp/context.go index 238bfc8f..b138e5ca 100644 --- a/libraries/authority-client/go/stamp/context.go +++ b/libraries/authority-client/go/stamp/context.go @@ -1,20 +1,20 @@ -// Package stamp binds the forwarded Work Context to the trusted hop's proof. +// Package stamp authenticates call attestations and extracts token bindings. package stamp import ( "crypto/sha256" "crypto/subtle" "encoding/hex" + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" "google.golang.org/grpc/metadata" ) -// Context returns only the token whose digest the authenticated hop stamped. -func Context(md metadata.MD, proof string) (string, error) { - proofs, tokens, digests := md.Get("x-codefly-hop-proof"), md.Get("x-codefly-work-context"), md.Get("x-codefly-hop-context") - if proof == "" || len(proofs) != 1 || subtle.ConstantTimeCompare([]byte(proofs[0]), []byte(proof)) != 1 { - return "", refusal.StampProof - } +// Context extracts one digest-bound token. It does NOT authenticate hop admission; +// use Check plus a shared ReplayStore (normally through hop.Verifier). Its +// refusals are caller-safe reasons: they name no token, digest or claim. +func Context(md metadata.MD) (string, error) { + tokens, digests := md.Get("x-codefly-work-context"), md.Get("x-codefly-hop-context") if len(tokens) != 1 || tokens[0] == "" || len(digests) != 1 { return "", refusal.StampToken } diff --git a/libraries/authority-client/go/workcontext/claims.go b/libraries/authority-client/go/workcontext/claims.go index 939d44c1..ad34f837 100644 --- a/libraries/authority-client/go/workcontext/claims.go +++ b/libraries/authority-client/go/workcontext/claims.go @@ -1,6 +1,8 @@ // Package workcontext authenticates the Work Context the hop forwarded to a -// module, and projects its claims. Authenticate is the one check a module's -// reading rests on: nothing here decodes a token it has not authenticated. +// module, and projects its claims. AuthenticateToken is the one check a +// module's reading rests on: nothing here decodes a token it has not +// authenticated. It establishes identity, never hop admission, which is +// hop.Verifier's. package workcontext import ( @@ -11,9 +13,6 @@ import ( "strings" "time" - "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" - "google.golang.org/grpc/metadata" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" core "github.com/codefly-dev/core/workcontext" "google.golang.org/protobuf/proto" @@ -43,31 +42,25 @@ type Claims struct { context *basev0.WorkContextV1 } -// Authenticate returns the claims of the Work Context the hop forwarded with -// md to this module (audience), after two checks, in order: +// AuthenticateToken authenticates one Work Context with Core's Inspector: +// canonical decode, the issuer pin, the Ed25519 signature under the published +// key the token names, the audience is this module, not-before and expiry +// within Core's skew, and the chain's structure and attenuation. It returns the +// claims, or a caller-safe refusal.Reason naming why they must not be believed. // -// 1. the stamp: md carries this run's hop proof, exactly one token, and the -// stamp's digest of that token, so the token is the one the authority's -// Check admitted for this call (stamp.Context); -// 2. the token itself, with Core's Inspector: canonical decode, the issuer -// pin, the Ed25519 signature under the published key the token names, the -// audience is this module, not-before and expiry within Core's skew, and -// the chain's structure and attenuation. +// This is the one identity-only reader here, and identity is all it +// establishes. It proves no hop admission: it does not check that a call was +// admitted for the procedure it arrived on, consumes no single-use nonce, and +// asks the authority nothing, so it checks no live revocation either. A service +// admits a call through hop.Verifier, which checks the hop's signed admission +// and reserves its nonce in a store every replica shares, and reaches here for +// the identity. // -// It consumes nothing and asks the authority nothing: liveness (revision, -// seal, single-use) is the authority's Check the hop made, which the digest -// binds to this call. A proof alone, or a digest alone, authenticates nothing: -// whoever holds the proof can write both, but cannot sign. -func Authenticate(md metadata.MD, proof, audience string, trust Trust) (*Claims, error) { - token, err := stamp.Context(md, proof) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, safeReason(err)) - } - return AuthenticateToken(token, audience, trust) -} - -// AuthenticateToken authenticates an unstamped token without consuming it or -// checking live revocation. Callers still enforce their operation's policy. +// A caller holding a call's metadata rather than a token extracts the token the +// stamp's digest binds with stamp.Context first. That is deliberately two +// calls (review r164 R4): a reader taking metadata reads as though it admitted +// the call, and it does not — a digest binding authorizes nothing, because +// whoever forwards a call can write both the token and its digest. func AuthenticateToken(token, audience string, trust Trust) (*Claims, error) { if audience == "" { return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, refusal.Configuration) diff --git a/libraries/authority-client/go/workcontext/claims_test.go b/libraries/authority-client/go/workcontext/claims_test.go index 02b9c555..166c4894 100644 --- a/libraries/authority-client/go/workcontext/claims_test.go +++ b/libraries/authority-client/go/workcontext/claims_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" reader "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" core "github.com/codefly-dev/core/workcontext" @@ -79,7 +80,7 @@ func tokenFor(t *testing.T, c *basev0.WorkContextV1) string { return signWith(t, func read(t *testing.T, c *basev0.WorkContextV1) *reader.Claims { t.Helper() - out, err := reader.Authenticate(stampedToken(tokenFor(t, c)), "test-proof", c.GetAudience(), trustFor(c)) + out, err := reader.AuthenticateToken(tokenFor(t, c), c.GetAudience(), trustFor(c)) if err != nil { t.Fatal(err) } @@ -157,7 +158,7 @@ func TestEnvelopesAndClaimsCoreRefuses(t *testing.T) { valid := tokenFor(t, session) payload, _, _ := strings.Cut(valid, ".") for _, token := range []string{"abc", "a.b.c", ".", "é.x", strings.Repeat("a", 32*1024+1), valid + "=", payload + ".AA", payload + "." + strings.Repeat("A", 85) + "B"} { - if _, err := reader.Authenticate(stampedToken(token), "test-proof", session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { + if _, err := reader.AuthenticateToken(token, session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { t.Fatalf("envelope not refused: %v", err) } } @@ -181,7 +182,7 @@ func TestEnvelopesAndClaimsCoreRefuses(t *testing.T) { b = append(b, b...) token = base64.RawURLEncoding.EncodeToString(b) + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(signer, b)) } - if _, err := reader.Authenticate(stampedToken(token), "test-proof", session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { + if _, err := reader.AuthenticateToken(token, session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { t.Fatalf("malformed claims not refused: %v", err) } }) @@ -214,7 +215,7 @@ func TestAForgedOrMisaddressedContextIsRefusedByName(t *testing.T) { "configuration": {tokenFor(t, session), "", "configuration", trust}, } { t.Run(name, func(t *testing.T) { - _, err := reader.Authenticate(stampedToken(c.token), "test-proof", c.audience, c.trust) + _, err := reader.AuthenticateToken(c.token, c.audience, c.trust) if !errors.Is(err, reader.ErrUnauthenticated) || !strings.Contains(err.Error(), c.reason) { t.Fatalf("want a refusal naming %q, got %v", c.reason, err) } @@ -226,26 +227,35 @@ func stampedToken(token string) metadata.MD { sum := sha256.Sum256([]byte(token)) return metadata.Pairs("x-codefly-work-context", token, "x-codefly-hop-proof", "test-proof", "x-codefly-hop-context", hex.EncodeToString(sum[:])) } -func TestAuthenticateRequiresTheStamp(t *testing.T) { + +// A caller holding a call's metadata extracts the bound token with +// stamp.Context before authenticating it (review r164 R4: two calls, so that +// neither reads as admitting the call). stamp.Context refuses the binding; +// admission is hop.Verifier's, and neither of these is that. +func TestTheBoundTokenIsExtractedBeforeItIsAuthenticated(t *testing.T) { session := body(t, "session") token := tokenFor(t, session) for _, change := range []func(metadata.MD){ - func(md metadata.MD) { md.Delete("x-codefly-hop-proof") }, - func(md metadata.MD) { md.Set("x-codefly-hop-proof", "forged") }, func(md metadata.MD) { md.Delete("x-codefly-hop-context") }, func(md metadata.MD) { md.Set("x-codefly-work-context", tokenFor(t, body(t, "operation"))) }, func(md metadata.MD) { md.Append("x-codefly-work-context", token) }, } { md := stampedToken(token) change(md) - if _, err := reader.Authenticate(md, "test-proof", session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { - t.Fatalf("unbound claims accepted: %v", err) + bound, err := stamp.Context(md) + if err == nil { + _, err = reader.AuthenticateToken(bound, session.GetAudience(), trustFor(session)) + } + if err == nil { + t.Fatal("unbound claims accepted") } } - if _, err := reader.Authenticate(stampedToken(token), "", session.GetAudience(), trustFor(session)); err == nil { - t.Fatal("empty proof accepted") + + bound, err := stamp.Context(stampedToken(token)) + if err != nil { + t.Fatal(err) } - if c, err := reader.Authenticate(stampedToken(token), "test-proof", session.GetAudience(), trustFor(session)); err != nil || c.Context().GetTenantId() != session.GetTenantId() { + if c, err := reader.AuthenticateToken(bound, session.GetAudience(), trustFor(session)); err != nil || c.Context().GetTenantId() != session.GetTenantId() { t.Fatalf("an authentic, bound context: %v", err) } } diff --git a/libraries/authority-client/go/workcontext/owner_test.go b/libraries/authority-client/go/workcontext/owner_test.go index 3491ce20..84e395bc 100644 --- a/libraries/authority-client/go/workcontext/owner_test.go +++ b/libraries/authority-client/go/workcontext/owner_test.go @@ -91,7 +91,7 @@ func TestAChainThatDropsOrChangesTheOwnerIsRefused(t *testing.T) { actor.GrantedScopes = []*basev0.WorkScopeV1{owned} } c.ActorChain[len(c.ActorChain)-1].GrantedScopes = []*basev0.WorkScopeV1{tc.last} - _, err := reader.Authenticate(stampedToken(tokenFor(t, c)), "test-proof", c.GetAudience(), trustFor(c)) + _, err := reader.AuthenticateToken(tokenFor(t, c), c.GetAudience(), trustFor(c)) if (err == nil) != tc.ok { t.Fatalf("authenticate: %v", err) } @@ -138,7 +138,7 @@ func TestResourceOwnerVectorsAreCores(t *testing.T) { authenticate := func(owner string) error { c := body(t, "session") c.AuthorityScopes = []*basev0.WorkScopeV1{ownedScope(t, "record", "read", owner)} - _, err := reader.Authenticate(stampedToken(tokenFor(t, c)), "test-proof", c.GetAudience(), trustFor(c)) + _, err := reader.AuthenticateToken(tokenFor(t, c), c.GetAudience(), trustFor(c)) return err } for _, v := range vectors.Accepted { diff --git a/libraries/authority-client/go/workcontext/vectors_test.go b/libraries/authority-client/go/workcontext/vectors_test.go index 6206d474..d1828da2 100644 --- a/libraries/authority-client/go/workcontext/vectors_test.go +++ b/libraries/authority-client/go/workcontext/vectors_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" reader "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" core "github.com/codefly-dev/core/workcontext" @@ -195,7 +196,11 @@ func TestTheSharedVectorsAreGosVerdicts(t *testing.T) { t.Run(mode, func(t *testing.T) { var err error if mode == "stamped" { - _, err = reader.Authenticate(stampedToken(v.Token), "test-proof", v.Audience, trust) + // The same verdict reached through the binding a hop writes. + bound, bindErr := stamp.Context(stampedToken(v.Token)) + if err = bindErr; err == nil { + _, err = reader.AuthenticateToken(bound, v.Audience, trust) + } } else { _, err = reader.AuthenticateToken(v.Token, v.Audience, trust) } diff --git a/libraries/authority-client/library.codefly.yaml b/libraries/authority-client/library.codefly.yaml index 3bcc5120..71aa1236 100644 --- a/libraries/authority-client/library.codefly.yaml +++ b/libraries/authority-client/library.codefly.yaml @@ -20,10 +20,10 @@ sources: module: authority service: local endpoint: grpc - contract-digest: sha256:58c434f9e507929a6cb98a6056464df2cbc27ad486b7c85c88c282d6fc092146 + contract-digest: sha256:1c51ec903d141b1df6c0eff0fcaa10aa48b6633e0753a13718bef83d086dc0cb services: - LocalService generated-by: - codefly: development + codefly: 0.1.215 companion: ghcr.io/codefly-dev/proto:0.0.18 facade: false diff --git a/libraries/authority-client/python/README.md b/libraries/authority-client/python/README.md index 93fc9ac9..55b33243 100644 --- a/libraries/authority-client/python/README.md +++ b/libraries/authority-client/python/README.md @@ -7,7 +7,7 @@ Context the hop forwarded: from authority_client.work_context import Trust, authenticate trust = Trust.from_keys(stub.Keys(KeysRequest())) # the authority interface's published keys -claims = authenticate(request.headers, proof, audience, trust) # raises Unauthenticated, naming why +claims = authenticate(request.headers, audience, trust) # raises Unauthenticated, naming why allowed = claims.allows(resource_kind, action, installed_resource_id) ``` @@ -19,13 +19,19 @@ for: from authority_client._gen.host.authority.v1.authority_pb2_grpc import AuthorityServiceStub from codefly_hop import Unauthenticated, Verifier -verifier = Verifier(AuthorityServiceStub(channel), audience=os.environ["CODEFLY__MODULE"], proof=proof) # once -identity = verifier.verify(request.headers) # a sync handler -identity = await verifier.verify_async(request.headers) # an asyncio handler: a key wait runs off the loop +verifier = Verifier(AuthorityServiceStub(channel), audience=os.environ["CODEFLY__MODULE"], replay=shared_replay_store) # once; the hop's key comes from Keys +identity = verifier.verify(request.headers, full_procedure) # a sync handler +identity = await verifier.verify_async(request.headers, full_procedure) # an asyncio handler: a key wait runs off the loop +with verifier.admit(request.headers, full_procedure) as identity: ... # holds the single-use reservation while the handler runs +async with verifier.admit_async(request.headers, full_procedure) as identity: ... identity.tenant, identity.has(kind, action), identity.claims.allows(kind, action, resource_id) ``` -The verifier checks the stamp first, then reads the published keys through `Keys` +The verifier requires the [signed admission protocol](../../hop/README.md), +including an atomic durable replay store shared across replicas, and refuses at +construction, outside the run named exactly `local`, a store whose `shared` is +not true (`replay_store_not_shared`). It checks the stamp first and reserves its +single-use nonce, then reads the published keys through `Keys` on a thread of its own with a 5 s timeout, one read at a time; it re-reads them on an unknown key (at most every 10 s) and when older than 5 minutes; keys that cannot be re-read are trusted for 15 minutes at most, and a call on keys older than @@ -43,8 +49,9 @@ as the Go library writes it. commit installs both, and no dependency is ever resolved by name from a public index. -`authenticate` checks the run's hop proof, exactly one token and the stamp's -digest of it; then Core's rules in Core's order: the decode path (size, the +`authenticate_token` is the one identity reader, not admission. It checks one +token (a caller holding a call's headers extracts the bound token with +`bound_token` first); then Core's rules in Core's order: the decode path (size, the contract's `buf.validate` schema rules run by protovalidate against the packaged contract descriptor, unknown fields, canonical encoding, a lifetime of at most a day), the issuer pin, the key (never Core's public conformance key), the Ed25519 @@ -126,7 +133,14 @@ values. These errors never retain underlying key-source errors or claim values. `KeysError` / `keys_error` / `keysError` is a separate internal diagnostic, not a caller response. Unknown future Core diagnostic categories remain refused. -Processes that receive only Issue responses use Go `NewTokenVerifier` or omit -`proof` when constructing Python/TypeScript `Verifier`. They still authenticate -bare tokens against published Keys; stamped verification is refused without a -configured run proof. A process receiving hop calls continues to require it. +Admission refusals carry their own codes, in the same scheme: `stamp_proof` (no +admission signed for this call), `stamp_token`, `stamp_digest`, `stamp_method`, +`stamp_audience`, `stamp_window`, `stamp_advisory`, `stamp_replayed` and +`stamp_store`. Each names no proof, token, digest, nonce or claim. + +Processes that receive only Issue responses use Go `NewTokenVerifier`, or omit +`replay` when constructing the Python/TypeScript `Verifier`. They still +authenticate bare tokens against published Keys. They admit no stamped call: +without a shared replay store an admission cannot be consumed once, so `verify` +refuses with `configuration`. A process that receives hop calls supplies one; +the hop's admission keys come from the same Keys read, never from configuration. diff --git a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py index 4bf50adb..2441c455 100644 --- a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py +++ b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py @@ -25,7 +25,7 @@ from authority_client._gen.host.authority.v1 import work_context_pb2 as host_dot_authority_dot_v1_dot_work__context__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!host/authority/v1/authority.proto\x12\x11host.authority.v1\x1a$host/authority/v1/work_context.proto\"\xf3\x04\n\x0cIssueRequest\x12:\n\x06person\x18\x01 \x01(\x0b\x32 .host.authority.v1.PersonSessionH\x00R\x06person\x12\x43\n\x08workload\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialH\x00R\x08workload\x12H\n\x0cinstallation\x18\x03 \x01(\x0b\x32\".host.authority.v1.InstallationRefH\x00R\x0cinstallation\x12#\n\x0cparent_token\x18\x04 \x01(\tH\x00R\x0bparentToken\x12\x42\n\ndelegation\x18\x0b \x01(\x0b\x32 .host.authority.v1.DelegationRefH\x00R\ndelegation\x12\x1a\n\x08\x61udience\x18\x05 \x01(\tR\x08\x61udience\x12\x36\n\x06scopes\x18\x06 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x1f\n\x0bttl_seconds\x18\x07 \x01(\x03R\nttlSeconds\x12\x30\n\x14operation_binding_id\x18\x08 \x01(\tR\x12operationBindingId\x12\'\n\x0forganization_id\x18\t \x01(\tR\x0eorganizationId\x12;\n\x05\x61\x63tor\x18\n \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\x1c\n\toperation\x18\x0c \x01(\tR\toperationB\x04\n\x02\x61s\"4\n\rDelegationRef\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"{\n\x0f\x44\x65legateRequest\x12\x16\n\x06module\x18\x01 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x02 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x03 \x01(\tR\x07purpose\"\xf6\x01\n\nDelegation\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12\x18\n\x07grantor\x18\x03 \x01(\tR\x07grantor\x12\x16\n\x06module\x18\x04 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x05 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x06 \x01(\tR\x07purpose\x12\x16\n\x06\x61\x63tive\x18\x07 \x01(\x08R\x06\x61\x63tive\"x\n\x14GetDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\">\n\x17RevokeDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"Y\n\x18RevokeDelegationResponse\x12=\n\ndelegation\x18\x01 \x01(\x0b\x32\x1d.host.authority.v1.DelegationR\ndelegation\"!\n\rPersonSessionJ\x04\x08\x01\x10\x02R\nsession_id\"4\n\x12WorkloadCredential\x12\x1e\n\ncredential\x18\x01 \x01(\tR\ncredential\":\n\x0fInstallationRef\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\x89\x01\n\rIssueResponse\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12&\n\x0f\x65xpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\x12:\n\x07\x63ontext\x18\x03 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\"t\n\x0c\x43heckRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12\x16\n\x06method\x18\x03 \x01(\tR\x06method\x12\x1a\n\x08\x61\x64visory\x18\x04 \x01(\x08R\x08\x61\x64visory\"\x9c\x01\n\rCheckResponse\x12:\n\x07\x63ontext\x18\x01 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\x12\x35\n\x16\x61uthorization_revision\x18\x02 \x01(\x04R\x15\x61uthorizationRevision\x12\x18\n\x07refusal\x18\x03 \x01(\tR\x07refusal\"\r\n\x0bKeysRequest\"X\n\x0cKeysResponse\x12\x16\n\x06issuer\x18\x01 \x01(\tR\x06issuer\x12\x30\n\x04keys\x18\x02 \x03(\x0b\x32\x1c.host.authority.v1.PublicKeyR\x04keys\"_\n\tPublicKey\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1c\n\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n\npublic_key\x18\x03 \x01(\x0cR\tpublicKey\"\x85\x01\n\x14ListResourcesRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x16\n\x06\x61\x63tion\x18\x04 \x01(\tR\x06\x61\x63tion\"Q\n\x15ListResourcesResponse\x12\x38\n\x06grants\x18\x01 \x03(\x0b\x32 .host.authority.v1.ResourceGrantR\x06grants\"\xb0\x01\n\x15\x41ttachResourceRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x41ttachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xc3\x01\n\x15\x44\x65tachResourceRequest\x12;\n\x05\x61\x63tor\x18\x01 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x44\x65tachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xff\x01\n\x18\x41uthorizeTransferRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\x12\"\n\rfrom_owner_id\x18\x05 \x01(\tR\x0b\x66romOwnerId\x12&\n\x0fto_principal_id\x18\x06 \x01(\tR\rtoPrincipalId\"c\n\x19\x41uthorizeTransferResponse\x12!\n\x0cprincipal_id\x18\x01 \x01(\tR\x0bprincipalId\x12#\n\rauthorized_by\x18\x02 \x01(\tR\x0c\x61uthorizedBy\"\xa0\x01\n\rResourceGrant\x12\x12\n\x03\x61ll\x18\x01 \x01(\x08H\x00R\x03\x61ll\x12\x10\n\x02id\x18\x02 \x01(\tH\x00R\x02id\x12\x18\n\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n\x07pattern\x18\x04 \x01(\tH\x00R\x07pattern\x12*\n\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\x07\n\x05match2\xaa\x07\n\x10\x41uthorityService\x12J\n\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\x12J\n\x05\x43heck\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\x12G\n\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12\x62\n\rListResources\x12\'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12\x65\n\x0e\x41ttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12\x65\n\x0e\x44\x65tachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n\x11\x41uthorizeTransfer\x12+.host.authority.v1.AuthorizeTransferRequest\x1a,.host.authority.v1.AuthorizeTransferResponse\x12M\n\x08\x44\x65legate\x12\".host.authority.v1.DelegateRequest\x1a\x1d.host.authority.v1.Delegation\x12k\n\x10RevokeDelegation\x12*.host.authority.v1.RevokeDelegationRequest\x1a+.host.authority.v1.RevokeDelegationResponse\x12W\n\rGetDelegation\x12\'.host.authority.v1.GetDelegationRequest\x1a\x1d.host.authority.v1.DelegationB/Z-gateway/pkg/gen/host/authority/v1;authorityv1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!host/authority/v1/authority.proto\x12\x11host.authority.v1\x1a$host/authority/v1/work_context.proto\"\xf3\x04\n\x0cIssueRequest\x12:\n\x06person\x18\x01 \x01(\x0b\x32 .host.authority.v1.PersonSessionH\x00R\x06person\x12\x43\n\x08workload\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialH\x00R\x08workload\x12H\n\x0cinstallation\x18\x03 \x01(\x0b\x32\".host.authority.v1.InstallationRefH\x00R\x0cinstallation\x12#\n\x0cparent_token\x18\x04 \x01(\tH\x00R\x0bparentToken\x12\x42\n\ndelegation\x18\x0b \x01(\x0b\x32 .host.authority.v1.DelegationRefH\x00R\ndelegation\x12\x1a\n\x08\x61udience\x18\x05 \x01(\tR\x08\x61udience\x12\x36\n\x06scopes\x18\x06 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x1f\n\x0bttl_seconds\x18\x07 \x01(\x03R\nttlSeconds\x12\x30\n\x14operation_binding_id\x18\x08 \x01(\tR\x12operationBindingId\x12\'\n\x0forganization_id\x18\t \x01(\tR\x0eorganizationId\x12;\n\x05\x61\x63tor\x18\n \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\x1c\n\toperation\x18\x0c \x01(\tR\toperationB\x04\n\x02\x61s\"4\n\rDelegationRef\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"{\n\x0f\x44\x65legateRequest\x12\x16\n\x06module\x18\x01 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x02 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x03 \x01(\tR\x07purpose\"\xf6\x01\n\nDelegation\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12\x18\n\x07grantor\x18\x03 \x01(\tR\x07grantor\x12\x16\n\x06module\x18\x04 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x05 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x06 \x01(\tR\x07purpose\x12\x16\n\x06\x61\x63tive\x18\x07 \x01(\x08R\x06\x61\x63tive\"x\n\x14GetDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\">\n\x17RevokeDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"Y\n\x18RevokeDelegationResponse\x12=\n\ndelegation\x18\x01 \x01(\x0b\x32\x1d.host.authority.v1.DelegationR\ndelegation\"!\n\rPersonSessionJ\x04\x08\x01\x10\x02R\nsession_id\"4\n\x12WorkloadCredential\x12\x1e\n\ncredential\x18\x01 \x01(\tR\ncredential\":\n\x0fInstallationRef\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\x89\x01\n\rIssueResponse\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12&\n\x0f\x65xpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\x12:\n\x07\x63ontext\x18\x03 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\"t\n\x0c\x43heckRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12\x16\n\x06method\x18\x03 \x01(\tR\x06method\x12\x1a\n\x08\x61\x64visory\x18\x04 \x01(\x08R\x08\x61\x64visory\"\x9c\x01\n\rCheckResponse\x12:\n\x07\x63ontext\x18\x01 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\x12\x35\n\x16\x61uthorization_revision\x18\x02 \x01(\x04R\x15\x61uthorizationRevision\x12\x18\n\x07refusal\x18\x03 \x01(\tR\x07refusal\"\r\n\x0bKeysRequest\"\x9d\x01\n\x0cKeysResponse\x12\x16\n\x06issuer\x18\x01 \x01(\tR\x06issuer\x12\x30\n\x04keys\x18\x02 \x03(\x0b\x32\x1c.host.authority.v1.PublicKeyR\x04keys\x12\x43\n\x0e\x61\x64mission_keys\x18\x03 \x03(\x0b\x32\x1c.host.authority.v1.PublicKeyR\radmissionKeys\"y\n\tPublicKey\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1c\n\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n\npublic_key\x18\x03 \x01(\x0cR\tpublicKey\x12\x18\n\x07purpose\x18\x04 \x01(\tR\x07purpose\"y\n\x1bRegisterAdmissionKeyRequest\x12;\n\x05\x61\x63tor\x18\x01 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\x1d\n\npublic_key\x18\x02 \x01(\x0cR\tpublicKey\"v\n\x1cRegisterAdmissionKeyResponse\x12.\n\x03key\x18\x01 \x01(\x0b\x32\x1c.host.authority.v1.PublicKeyR\x03key\x12&\n\x0f\x65xpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\"\x85\x01\n\x14ListResourcesRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x16\n\x06\x61\x63tion\x18\x04 \x01(\tR\x06\x61\x63tion\"Q\n\x15ListResourcesResponse\x12\x38\n\x06grants\x18\x01 \x03(\x0b\x32 .host.authority.v1.ResourceGrantR\x06grants\"\xb0\x01\n\x15\x41ttachResourceRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x41ttachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xc3\x01\n\x15\x44\x65tachResourceRequest\x12;\n\x05\x61\x63tor\x18\x01 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x44\x65tachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xff\x01\n\x18\x41uthorizeTransferRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\x12\"\n\rfrom_owner_id\x18\x05 \x01(\tR\x0b\x66romOwnerId\x12&\n\x0fto_principal_id\x18\x06 \x01(\tR\rtoPrincipalId\"c\n\x19\x41uthorizeTransferResponse\x12!\n\x0cprincipal_id\x18\x01 \x01(\tR\x0bprincipalId\x12#\n\rauthorized_by\x18\x02 \x01(\tR\x0c\x61uthorizedBy\"\xa0\x01\n\rResourceGrant\x12\x12\n\x03\x61ll\x18\x01 \x01(\x08H\x00R\x03\x61ll\x12\x10\n\x02id\x18\x02 \x01(\tH\x00R\x02id\x12\x18\n\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n\x07pattern\x18\x04 \x01(\tH\x00R\x07pattern\x12*\n\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\x07\n\x05match2\xa3\x08\n\x10\x41uthorityService\x12J\n\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\x12J\n\x05\x43heck\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\x12G\n\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12w\n\x14RegisterAdmissionKey\x12..host.authority.v1.RegisterAdmissionKeyRequest\x1a/.host.authority.v1.RegisterAdmissionKeyResponse\x12\x62\n\rListResources\x12\'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12\x65\n\x0e\x41ttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12\x65\n\x0e\x44\x65tachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n\x11\x41uthorizeTransfer\x12+.host.authority.v1.AuthorizeTransferRequest\x1a,.host.authority.v1.AuthorizeTransferResponse\x12M\n\x08\x44\x65legate\x12\".host.authority.v1.DelegateRequest\x1a\x1d.host.authority.v1.Delegation\x12k\n\x10RevokeDelegation\x12*.host.authority.v1.RevokeDelegationRequest\x1a+.host.authority.v1.RevokeDelegationResponse\x12W\n\rGetDelegation\x12\'.host.authority.v1.GetDelegationRequest\x1a\x1d.host.authority.v1.DelegationB/Z-gateway/pkg/gen/host/authority/v1;authorityv1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -61,28 +61,32 @@ _globals['_CHECKRESPONSE']._serialized_end=1993 _globals['_KEYSREQUEST']._serialized_start=1995 _globals['_KEYSREQUEST']._serialized_end=2008 - _globals['_KEYSRESPONSE']._serialized_start=2010 - _globals['_KEYSRESPONSE']._serialized_end=2098 - _globals['_PUBLICKEY']._serialized_start=2100 - _globals['_PUBLICKEY']._serialized_end=2195 - _globals['_LISTRESOURCESREQUEST']._serialized_start=2198 - _globals['_LISTRESOURCESREQUEST']._serialized_end=2331 - _globals['_LISTRESOURCESRESPONSE']._serialized_start=2333 - _globals['_LISTRESOURCESRESPONSE']._serialized_end=2414 - _globals['_ATTACHRESOURCEREQUEST']._serialized_start=2417 - _globals['_ATTACHRESOURCEREQUEST']._serialized_end=2593 - _globals['_ATTACHRESOURCERESPONSE']._serialized_start=2595 - _globals['_ATTACHRESOURCERESPONSE']._serialized_end=2660 - _globals['_DETACHRESOURCEREQUEST']._serialized_start=2663 - _globals['_DETACHRESOURCEREQUEST']._serialized_end=2858 - _globals['_DETACHRESOURCERESPONSE']._serialized_start=2860 - _globals['_DETACHRESOURCERESPONSE']._serialized_end=2925 - _globals['_AUTHORIZETRANSFERREQUEST']._serialized_start=2928 - _globals['_AUTHORIZETRANSFERREQUEST']._serialized_end=3183 - _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_start=3185 - _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_end=3284 - _globals['_RESOURCEGRANT']._serialized_start=3287 - _globals['_RESOURCEGRANT']._serialized_end=3447 - _globals['_AUTHORITYSERVICE']._serialized_start=3450 - _globals['_AUTHORITYSERVICE']._serialized_end=4388 + _globals['_KEYSRESPONSE']._serialized_start=2011 + _globals['_KEYSRESPONSE']._serialized_end=2168 + _globals['_PUBLICKEY']._serialized_start=2170 + _globals['_PUBLICKEY']._serialized_end=2291 + _globals['_REGISTERADMISSIONKEYREQUEST']._serialized_start=2293 + _globals['_REGISTERADMISSIONKEYREQUEST']._serialized_end=2414 + _globals['_REGISTERADMISSIONKEYRESPONSE']._serialized_start=2416 + _globals['_REGISTERADMISSIONKEYRESPONSE']._serialized_end=2534 + _globals['_LISTRESOURCESREQUEST']._serialized_start=2537 + _globals['_LISTRESOURCESREQUEST']._serialized_end=2670 + _globals['_LISTRESOURCESRESPONSE']._serialized_start=2672 + _globals['_LISTRESOURCESRESPONSE']._serialized_end=2753 + _globals['_ATTACHRESOURCEREQUEST']._serialized_start=2756 + _globals['_ATTACHRESOURCEREQUEST']._serialized_end=2932 + _globals['_ATTACHRESOURCERESPONSE']._serialized_start=2934 + _globals['_ATTACHRESOURCERESPONSE']._serialized_end=2999 + _globals['_DETACHRESOURCEREQUEST']._serialized_start=3002 + _globals['_DETACHRESOURCEREQUEST']._serialized_end=3197 + _globals['_DETACHRESOURCERESPONSE']._serialized_start=3199 + _globals['_DETACHRESOURCERESPONSE']._serialized_end=3264 + _globals['_AUTHORIZETRANSFERREQUEST']._serialized_start=3267 + _globals['_AUTHORIZETRANSFERREQUEST']._serialized_end=3522 + _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_start=3524 + _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_end=3623 + _globals['_RESOURCEGRANT']._serialized_start=3626 + _globals['_RESOURCEGRANT']._serialized_end=3786 + _globals['_AUTHORITYSERVICE']._serialized_start=3789 + _globals['_AUTHORITYSERVICE']._serialized_end=4848 # @@protoc_insertion_point(module_scope) diff --git a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py index eed48328..1823c757 100644 --- a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py +++ b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py @@ -50,6 +50,11 @@ def __init__(self, channel): request_serializer=host_dot_authority_dot_v1_dot_authority__pb2.KeysRequest.SerializeToString, response_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.KeysResponse.FromString, _registered_method=True) + self.RegisterAdmissionKey = channel.unary_unary( + '/host.authority.v1.AuthorityService/RegisterAdmissionKey', + request_serializer=host_dot_authority_dot_v1_dot_authority__pb2.RegisterAdmissionKeyRequest.SerializeToString, + response_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.RegisterAdmissionKeyResponse.FromString, + _registered_method=True) self.ListResources = channel.unary_unary( '/host.authority.v1.AuthorityService/ListResources', request_serializer=host_dot_authority_dot_v1_dot_authority__pb2.ListResourcesRequest.SerializeToString, @@ -111,7 +116,25 @@ def Check(self, request, context): raise NotImplementedError('Method not implemented!') def Keys(self, request, context): - """Keys returns the issuer and the public keys a verifier pins. + """Keys returns the issuer and the public keys a verifier pins: the Work + Context keys, and the hop admission keys a module behind the hop checks + each call's admission by. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def RegisterAdmissionKey(self, request, context): + """RegisterAdmissionKey is how the hop tells the authority the public key it + signs call admissions with, so Keys publishes it in admission_keys. The + caller is the hop, proving it with its workload credential; any other + workload is refused by name. Only the public half travels: the hop's seed + never leaves it. Registering the same key again is idempotent and renews + it, and a registration lapses at expires_at_unix unless renewed, so a hop + renews while it runs (a restarted authority learns the key again at the + next renewal) and a key no running hop signs with any more is withdrawn + without being named. While a rotation overlaps, the retiring and the new + key are both registered, and both published. """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') @@ -234,6 +257,11 @@ def add_AuthorityServiceServicer_to_server(servicer, server): request_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.KeysRequest.FromString, response_serializer=host_dot_authority_dot_v1_dot_authority__pb2.KeysResponse.SerializeToString, ), + 'RegisterAdmissionKey': grpc.unary_unary_rpc_method_handler( + servicer.RegisterAdmissionKey, + request_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.RegisterAdmissionKeyRequest.FromString, + response_serializer=host_dot_authority_dot_v1_dot_authority__pb2.RegisterAdmissionKeyResponse.SerializeToString, + ), 'ListResources': grpc.unary_unary_rpc_method_handler( servicer.ListResources, request_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.ListResourcesRequest.FromString, @@ -362,6 +390,33 @@ def Keys(request, metadata, _registered_method=True) + @staticmethod + def RegisterAdmissionKey(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/host.authority.v1.AuthorityService/RegisterAdmissionKey', + host_dot_authority_dot_v1_dot_authority__pb2.RegisterAdmissionKeyRequest.SerializeToString, + host_dot_authority_dot_v1_dot_authority__pb2.RegisterAdmissionKeyResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + @staticmethod def ListResources(request, target, diff --git a/libraries/authority-client/python/authority_client/work_context.py b/libraries/authority-client/python/authority_client/work_context.py index 85313874..24608f7b 100644 --- a/libraries/authority-client/python/authority_client/work_context.py +++ b/libraries/authority-client/python/authority_client/work_context.py @@ -1,6 +1,6 @@ """Authenticate the Work Context the hop forwarded to a module, then project it. -``authenticate`` is the Go library's ``workcontext.Authenticate``, check for +``authenticate_token`` is the Go library's ``workcontext.AuthenticateToken``, check for check: the stamp binds exactly one token to this run's hop proof by its digest, and the token itself must carry a valid Ed25519 signature under the authority's published key, name the pinned issuer, be addressed to this module and be inside @@ -77,6 +77,14 @@ class Reason(str, Enum): STAMPTOKEN = "stamp_token" STAMPDIGEST = "stamp_digest" STAMPIDENTITY = "stamp_identity" + STAMPMETHOD = "stamp_method" + STAMPAUDIENCE = "stamp_audience" + STAMPWINDOW = "stamp_window" + STAMPISSUED = "stamp_issued" + STAMPADVISORY = "stamp_advisory" + STAMPREPLAYED = "stamp_replayed" + STAMPSTORE = "stamp_store" + STORENOTSHARED = "replay_store_not_shared" KEYSUNAVAILABLE = "keys_unavailable" CANCELLED = "cancelled" MALFORMED = "malformed" @@ -95,10 +103,18 @@ class Reason(str, Enum): _REASON_TEXT = { Reason.CONFIGURATION: 'the verifier configuration is incomplete', - Reason.STAMPPROOF: "the call requires this run's hop proof", + Reason.STAMPPROOF: 'the call carries no hop admission signed for it', Reason.STAMPTOKEN: 'the stamp binds no single Work Context', Reason.STAMPDIGEST: 'the Work Context does not match the hop stamp', Reason.STAMPIDENTITY: 'the stamp does not match the authenticated Work Context', + Reason.STAMPMETHOD: 'the hop admission was signed for another procedure', + Reason.STAMPAUDIENCE: 'the hop admission was signed for another module', + Reason.STAMPWINDOW: 'the hop admission is expired or outside its validity window', + Reason.STAMPISSUED: 'the hop admission is issued further ahead than the clock allowance', + Reason.STAMPADVISORY: 'an advisory policy refusal cannot admit a call', + Reason.STAMPREPLAYED: 'the hop admission has already been used', + Reason.STORENOTSHARED: "the admission store must be durable and shared outside local", + Reason.STAMPSTORE: 'the hop admission store is unavailable', Reason.KEYSUNAVAILABLE: "the authority's published keys are unavailable", Reason.CANCELLED: "the call ended while the authority's keys were being read", Reason.MALFORMED: 'the Work Context encoding or claims are invalid', @@ -146,10 +162,12 @@ def __init__(self, issuer: str, keys: Mapping[str, bytes], *, now: Callable[[], @classmethod def from_keys(cls, response, *, now: Callable[[], datetime] | None = None) -> "Trust": """Trust built from the authority interface's KeysResponse. Only Ed25519 - keys are kept; an empty or repeated key id is refused.""" + Work Context keys are kept (purpose "work-context", or none from a host + older than the purpose); an empty or repeated key id is refused. The + hop's admission keys are never among them.""" keys: dict[str, bytes] = {} for key in response.keys: - if key.algorithm != ALGORITHM: + if key.algorithm != ALGORITHM or key.purpose not in ("", "work-context"): continue if not key.key_id or key.key_id in keys: raise Unauthenticated(Reason.INVALIDKEY) @@ -201,18 +219,21 @@ def allows_owned(self, kind: str, action: str, resource_id: str, owner_id: str) ) -def authenticate( - headers: Mapping[str, str] | Iterable[tuple[str, str]], proof: str, audience: str, trust: Trust -) -> Claims: - """The claims of the Work Context the hop forwarded with headers to this module - (audience), or ``Unauthenticated`` naming why it must not be believed.""" - if not audience: - raise Unauthenticated(Reason.CONFIGURATION) - return authenticate_token(bound_token(headers, proof), audience, trust) - - def authenticate_token(token: str, audience: str, trust: Trust) -> Claims: - """Authenticate bare bytes; no hop admission, live checks or consumption.""" + """The claims of one Work Context, for this module (``audience``), or + ``Unauthenticated`` naming why they must not be believed. + + This is the one identity-only reader here, and identity is all it + establishes. It proves no hop admission: it does not check that a call was + admitted for the procedure it arrived on, consumes no single-use nonce, and + asks the authority nothing, so it checks no live revocation either. A + service admits a call with ``codefly_hop.Verifier.verify``. + + A caller holding a call's headers extracts the token the stamp's digest + binds with ``bound_token`` first. That is deliberately two calls (security + review r164 R4): a reader taking headers reads as though it admitted the + call, and it does not. + """ if not audience: raise Unauthenticated(Reason.CONFIGURATION) if len(token) > MAX_TOKEN_BYTES or not token.isascii() or token.count(".") != 1: @@ -322,13 +343,10 @@ def _check_structure(context) -> None: raise Unauthenticated(Reason.GRANT) -def bound_token(headers: Mapping[str, str] | Iterable[tuple[str, str]], proof: str) -> str: - """The one token the stamp's digest binds under this run's proof. Bound, not - authenticated: whoever holds the proof can write both.""" +def bound_token(headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> str: + """Extract a digest-bound token; this does not authenticate hop admission.""" values = collect(headers) - proofs, tokens, digests = values.get(PROOF_KEY, []), values.get(WORK_CONTEXT_KEY, []), values.get(CONTEXT_KEY, []) - if not proof or len(proofs) != 1 or not hmac.compare_digest(proofs[0].encode(), proof.encode()): - raise Unauthenticated(Reason.STAMPPROOF) + tokens, digests = values.get(WORK_CONTEXT_KEY, []), values.get(CONTEXT_KEY, []) if len(tokens) != 1 or not tokens[0] or len(digests) != 1: raise Unauthenticated(Reason.STAMPTOKEN) digest = hashlib.sha256(tokens[0].encode(errors="surrogatepass")).hexdigest() diff --git a/libraries/authority-client/python/codefly_hop/__init__.py b/libraries/authority-client/python/codefly_hop/__init__.py index 1e3d16e1..8db92ac1 100644 --- a/libraries/authority-client/python/codefly_hop/__init__.py +++ b/libraries/authority-client/python/codefly_hop/__init__.py @@ -1,36 +1,31 @@ -"""The authority hop's stamp, as a module served in Python authenticates it. - -The hop in front of a module asked the authority's Check, which verified the -caller's Work Context live and applied what the method declares, then wrote who -the call is for, this run's proof and the digest of the checked token: the -stamp. The module re-checks no permission the hop decided, but believes nothing -it has not authenticated. ``Verifier`` is the Go library's ``hop.Verifier`` -(libraries/hop/go), rule for rule: the token is authenticated against the -authority's published keys (``authority_client.work_context.authenticate``), who -the call is for is read from its claims, and a stamp that says otherwise is -refused. Both read the one fixture the Go hop writes (libraries/hop/testdata). - -It ships in the authority-client distribution, beside the ``authority_client`` -package it rests on, so one pinned commit installs both and no dependency is -ever looked up by name on a public index. +"""Method-bound, single-use hop admission for Python services. + +The hop alone holds a signing seed. The authority publishes its public half in +Keys (admission_keys), which verifiers already read: they check each admission +by a published key, trusting every one while a key rotates, authenticate the +Work Context and consume each signed nonce in a durable store shared by all +replicas. See libraries/hop/README.md for the wire contract. """ from __future__ import annotations import asyncio import base64 +import os import threading import time from collections.abc import Callable, Iterable, Mapping from concurrent import futures +from contextlib import asynccontextmanager, contextmanager from dataclasses import dataclass, field from datetime import datetime, timezone +from .call import SKEW, Expired, Replayed, ReplayStore, ReservationStore, admission_keys, check, signed_by from authority_client._gen.host.authority.v1.authority_pb2 import KeysRequest from authority_client._gen.host.authority.v1.work_context_pb2 import WorkContextV1 from authority_client.work_context import Claims, Trust, Unauthenticated, Reason, authenticate_token, bound_token, collect -__all__ = ["Identity", "Verifier", "Unauthenticated", "Reason", "identity_of"] +__all__ = ["Identity", "Verifier", "Unauthenticated", "Reason", "identity_of", "ReplayStore", "ReservationStore", "Replayed", "Expired", "NoAdmissionKeys"] WORK_CONTEXT_KEY = "x-codefly-work-context" STAMP_PREFIX = "x-codefly-hop-" @@ -98,23 +93,53 @@ def identity_of(context) -> Identity: ) +def _store_refusal(error: Exception) -> Unauthenticated: + """The refusal a failed reservation answers: never the backend's error.""" + if isinstance(error, Unauthenticated): + return error + if isinstance(error, Replayed): + return Unauthenticated(Reason.STAMPREPLAYED) + if isinstance(error, Expired): + return Unauthenticated(Reason.STAMPWINDOW) + return Unauthenticated(Reason.STAMPSTORE) + + +def _within(until: int, arrival: float, mark: float) -> None: + """A store without ``reserve`` keeps a nonce to its retention and may then + evict it. A consumption that only succeeded because that retention had + passed found an evicted nonce, not a new one: it is refused on the window, + so a second delivery never runs.""" + if arrival + (time.monotonic() - mark) >= until: + raise Unauthenticated(Reason.STAMPWINDOW) + + class Verifier: """The one module-side entry point: authenticates every call the hop forwards. ``keys`` is the authority interface's generated ``AuthorityServiceStub`` (or - anything with its ``Keys``): the published keys are read through it, never - typed in. Build it once at startup; it never fails on an authority that is not - serving yet, and refuses calls, with the reason, until the keys are read. + anything with its ``Keys``): the published keys, the Work Context's and the + hop's admission keys, are read through it, never typed in. Build it once at + startup; it never fails on an authority that is not serving yet, and refuses + calls, with the reason, until the keys are read. Without ``replay`` it + authenticates bare tokens only and admits no call. """ - def __init__(self, keys, *, audience: str, proof: str | None = None, now: Callable[[], float] | None = None, - timeout: float = KEY_TIMEOUT) -> None: - if keys is None or not audience or proof == "": + def __init__(self, keys, *, audience: str, replay: ReplayStore | None = None, + now: Callable[[], float] | None = None, timeout: float = KEY_TIMEOUT) -> None: + admitting = replay is not None + if keys is None or not audience: raise Unauthenticated(Reason.CONFIGURATION) - self._source, self._audience, self._proof, self._timeout = keys, audience, proof or "", timeout + if admitting and os.environ.get("CODEFLY__ENVIRONMENT") != "local" and getattr(replay, "shared", False) is not True: + # A process-local store answers only for its own process: a captured + # admission would replay once per replica, and a restart would + # forget every reservation. Only the run named exactly "local" may. + raise Unauthenticated(Reason.STORENOTSHARED) + self._source, self._audience, self._timeout = keys, audience, timeout + self._replay = replay self._now = now or time.time self._lock = threading.Lock() self._trust: Trust | None = None + self._admission: tuple[str, ...] = () # the published hop admission keys, hex self._fetched = 0.0 self._tried = float("-inf") self._inflight: futures.Future[None] | None = None # done when the running read ends @@ -129,41 +154,142 @@ def ready(self) -> bool: succeeded or once the keys are stale. Readiness uses this, never ``keys_error``: a brief authority outage must not take a replica out of rotation while its keys are still trusted. It also starts the read a call - would (keys missing or older than 5 min, at most every 10 s, never waiting), - so an unready replica, which gets no calls, recovers, and an idle one never + would (keys missing or older than 5 min, or no admission key published, at + most every 10 s, never waiting), so an unready replica, which gets no calls, recovers, and an idle one never goes stale.""" with self._lock: now = self._now() - if (self._trust is None or now - self._fetched > KEY_MAX_AGE) and self._inflight is None and now - self._tried >= KEY_RETRY: + unadmitting = self._replay is not None and not self._admission + if (self._trust is None or now - self._fetched > KEY_MAX_AGE or unadmitting) and self._inflight is None and now - self._tried >= KEY_RETRY: self._refresh() return self._trust is not None and now - self._fetched <= KEY_STALE - def verify(self, headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> Identity: + def verify(self, headers: Mapping[str, str] | Iterable[tuple[str, str]], method: str) -> Identity: """Who the call is for, or ``Unauthenticated`` naming why it is refused. - The stamp is checked first: nothing unbound reaches the key read. It may - wait up to the read's timeout for a key it does not hold yet, or for keys - past the stale window; an asyncio handler uses ``verify_async``.""" - pairs, token = self._bound(headers) - wait = self._start(_key_id(token)) - if wait is not None: - futures.wait((wait,), self._timeout) - return self._finish(pairs, token) - - async def verify_async(self, headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> Identity: + The admission is checked first, by the published hop admission keys + held: nothing that is not signed for this exact method and audience + reaches the Work Context's key read or starts one, and its single-use + nonce is reserved before that read. It may wait up to the read's timeout + for a key it does not hold yet, or for keys past the stale window; an + asyncio handler uses ``verify_async``. + + The reservation's in-flight hold ends when ``verify`` returns, so it + lasts to the admission's expiry plus the clock allowance. A handler + whose work follows uses ``admit``, which holds it until that work ends.""" + identity, finish = self._admit(headers, method) + finish() + return identity + + @contextmanager + def admit(self, headers: Mapping[str, str] | Iterable[tuple[str, str]], method: str): + """``verify`` that holds the single-use reservation until the block + exits, however it exits. Enter it as soon as the call's headers have + arrived, before its body is read:: + + with verifier.admit(metadata, method) as identity: + ... # the handler + """ + identity, finish = self._admit(headers, method) + try: + yield identity + finally: + finish() + + async def verify_async(self, headers: Mapping[str, str] | Iterable[tuple[str, str]], method: str) -> Identity: """``verify`` for an asyncio handler: a wait for the keys is awaited on the event loop and holds no thread, so any number of waiting calls leave the loop's executor free; a call whose key is held never waits.""" - pairs, token = self._bound(headers) - wait = self._start(_key_id(token)) + identity, finish = await self._admit_async(headers, method) + await finish() + return identity + + @asynccontextmanager + async def admit_async(self, headers: Mapping[str, str] | Iterable[tuple[str, str]], method: str): + """``admit`` for an asyncio handler.""" + identity, finish = await self._admit_async(headers, method) + try: + yield identity + finally: + await finish() + + def _admit(self, headers, method): + arrival, mark = self._now(), time.monotonic() + pairs = self._pairs(headers) + wait = self._admission_read() + if wait is not None: + futures.wait((wait,), self._timeout) + pairs, token, call = self._bound(pairs, method, arrival) + managed = _managed(self._replay, "reserve", "finish") + until = call.expires + SKEW + try: + if managed: + self._replay.reserve(call.nonce, until) + else: + self._replay.consume(call.nonce, until) + except Exception as error: + raise _store_refusal(error) from error + if not managed: + _within(until, arrival, mark) + + def finish(): + if managed: + try: + self._replay.finish(call.nonce) + except Exception: + pass # a failed release keeps the reservation held: never reopen + try: + wait = self._start(_key_id(token)) + if wait is not None: + futures.wait((wait,), self._timeout) + return self._finish(pairs, token), finish + except BaseException: + finish() + raise + + async def _admit_async(self, headers, method): + arrival, mark = self._now(), time.monotonic() + pairs = self._pairs(headers) + wait = self._admission_read() if wait is not None: try: await asyncio.wait_for(asyncio.wrap_future(wait), self._timeout) except asyncio.TimeoutError: - pass # judged below on the keys held: stale ones are refused with the reason - return self._finish(pairs, token) + pass # judged below on the keys held: missing or stale ones are refused with the reason + pairs, token, call = self._bound(pairs, method, arrival) + managed = _managed(self._replay, "reserve_async", "finish_async") + until = call.expires + SKEW + try: + if managed: + await self._replay.reserve_async(call.nonce, until) + else: + await self._replay.consume_async(call.nonce, until) + except Exception as error: + raise _store_refusal(error) from error + if not managed: + _within(until, arrival, mark) + + async def finish(): + if managed: + try: + await self._replay.finish_async(call.nonce) + except Exception: + pass # a failed release keeps the reservation held: never reopen + try: + wait = self._start(_key_id(token)) + if wait is not None: + try: + await asyncio.wait_for(asyncio.wrap_future(wait), self._timeout) + except asyncio.TimeoutError: + pass # judged below on the keys held: stale ones are refused with the reason + return self._finish(pairs, token), finish + except BaseException: + await finish() + raise def verify_token(self, token: str) -> Identity: - """Authenticate a bare token with the same fail-closed key cache.""" + """Authenticate a bare token with the same fail-closed key cache. It + establishes no admission: no method binding is checked and no nonce is + consumed, so a service admitting hop calls uses ``verify``.""" wait = self._start(_key_id(token)) if wait is not None: futures.wait((wait,), self._timeout) @@ -179,9 +305,44 @@ async def verify_token_async(self, token: str) -> Identity: pass return self._token_identity(token) - def _bound(self, headers) -> tuple[list[tuple[str, str]], str]: - pairs = list(headers.items() if isinstance(headers, Mapping) else headers) - return pairs, bound_token(pairs, self._proof) + def _pairs(self, headers) -> list[tuple[str, str]]: + if self._replay is None: + # A token-only verifier holds no replay store, so it can establish + # no admission. + raise Unauthenticated(Reason.CONFIGURATION) + return list(headers.items() if isinstance(headers, Mapping) else headers) + + def _admission_read(self) -> futures.Future[None] | None: + """The read an admission must wait for, if any. An admission is judged on + the keys held: neither a stamp signed by no published key nor keys past + ``KEY_MAX_AGE`` start a read here, so a caller without an admission can + neither drive the authority's keys nor delay a genuine call's read. Only + when no trusted keys are held at all (never read, or past ``KEY_STALE``), + or the last read published no admission key (the hop had not registered + yet), does it start one, at most every ``KEY_RETRY`` as readiness does.""" + with self._lock: + now = self._now() + trusted = self._trust is not None and now - self._fetched <= KEY_STALE and bool(self._admission) + if not trusted and self._inflight is None and now - self._tried >= KEY_RETRY: + self._refresh() + return None if trusted else self._inflight + + def _bound(self, pairs, method, arrival): + """The admission judged at the instant the call arrived, by the published + hop admission key it is signed by.""" + with self._lock: + if self._trust is None or self._now() - self._fetched > KEY_STALE: + if isinstance(self.keys_error, Unauthenticated) and self.keys_error.reason == Reason.INVALIDKEY: + raise Unauthenticated(Reason.INVALIDKEY) + raise Unauthenticated(Reason.KEYSUNAVAILABLE) + published = self._admission + if not published: + raise Unauthenticated(Reason.KEYSUNAVAILABLE) + key = signed_by(pairs, published) + if not key: + raise Unauthenticated(Reason.STAMPPROOF) + call = check(pairs, key, method, self._audience, arrival) + return pairs, bound_token(pairs), call def _start(self, key_id: str) -> futures.Future[None] | None: """Start a read when one is due; return it when this call must wait for it: @@ -221,8 +382,9 @@ def _finish(self, pairs: list[tuple[str, str]], token: str) -> Identity: got = values.get(key, []) if len(got) > 1 or (got[0] if got else "") != want: raise Unauthenticated(Reason.STAMPIDENTITY) - advisory = (values.get(ADVISORY_KEY) or [""])[0] - return Identity(**{**identity.__dict__, "context": token, "advisory": advisory}) + # An admission carrying an advisory refusal is refused by ``check``, so + # an admitted call never has one: diagnostic identity is never authority. + return Identity(**{**identity.__dict__, "context": token, "advisory": "", "claims": identity.claims}) def _refresh(self) -> futures.Future[None]: """Start the one read, on its own thread and timeout; the lock is held. The @@ -236,15 +398,18 @@ def _refresh(self) -> futures.Future[None]: self._inflight, self._tried = done, self._now() def read() -> None: - trust, error = None, None + trust, admission, error = None, (), None try: response = self._source.Keys(KeysRequest(), timeout=self._timeout) trust = Trust.from_keys(response, now=lambda: datetime.fromtimestamp(self._now(), timezone.utc)) + admission = admission_keys(response) + if self._replay is not None and not admission: + error = NoAdmissionKeys() except Exception as failure: # a gRPC error, a timeout, or keys that are unusable error = failure with self._lock: if trust is not None: - self._trust, self._fetched = trust, self._now() + self._trust, self._admission, self._fetched = trust, admission, self._now() self.keys_error, self._inflight = error, None done.set_result(None) @@ -252,6 +417,22 @@ def read() -> None: return done +class NoAdmissionKeys(Exception): + """What ``keys_error`` reports, for the module's logs, while the authority's + keys read but publish no hop admission key: every call is refused as + keys_unavailable until the authority publishes its hop's key.""" + + def __init__(self) -> None: + super().__init__("the authority's Keys publish no hop admission key (admission_keys), so no call can be admitted") + + +def _managed(store, reserve: str, finish: str) -> bool: + """Whether a store holds reservations: it has both operations, as + TypeScript requires. A store with only one is treated as a plain consumer, + never left holding a reservation nothing finishes.""" + return callable(getattr(store, reserve, None)) and callable(getattr(store, finish, None)) + + def _key_id(token: str) -> str: """The key a token names, read only to choose the keys it is judged by.""" if len(token) > 32 * 1024: diff --git a/libraries/authority-client/python/codefly_hop/call.py b/libraries/authority-client/python/codefly_hop/call.py new file mode 100644 index 00000000..11e9a552 --- /dev/null +++ b/libraries/authority-client/python/codefly_hop/call.py @@ -0,0 +1,166 @@ +"""Signed hop admission v1. Replay consumption is supplied by the host, shared across replicas.""" +import base64 +import hashlib +import json +import re +from dataclasses import dataclass +from typing import Protocol + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from authority_client.work_context import Reason, Unauthenticated, collect + + +class Replayed(Exception): + """The shared store has already consumed this nonce.""" + + +class ReplayStore(Protocol): + shared: bool # true only for durable storage shared by every replica + + def consume(self, nonce: str, retain_until: int) -> None: + """Atomically reserve nonce until Unix seconds; raise Replayed on reuse, another exception on unavailability. + + Every replica must use the same durable store. No process-local default. + """ + ... + + async def consume_async(self, nonce: str, retain_until: int) -> None: + """The same atomic operation without blocking the event loop.""" + ... + + +# The hop admission lifetime and the clock allowance, as Go's stamp.Lifetime +# and stamp.Skew define them. An admission may be issued up to SKEW ahead of +# this verifier's clock and stays admissible until SKEW past its expiry; the +# signed span is still capped at LIFETIME exactly. +LIFETIME, SKEW = 30, 2 + + +# What a published admission key verifies: Keys carries the hop's keys in +# admission_keys with this purpose, never among the Work Context keys. +PURPOSE = "hop-admission" + + +def admission_keys(response) -> tuple[str, ...]: + """The hop admission keys a KeysResponse publishes, hex: Ed25519 keys in + ``admission_keys`` with purpose ``PURPOSE``; anything else there is not one.""" + out: list[str] = [] + for key in response.admission_keys: + if key.algorithm != "Ed25519" or key.purpose != PURPOSE: + continue + if len(key.public_key) != 32: + raise Unauthenticated(Reason.INVALIDKEY) + if key.public_key.hex() not in out: + out.append(key.public_key.hex()) + return tuple(out) + + +def _proof(values) -> tuple[bytes, bytes] | None: + """The one admission the headers carry, as its signed payload and signature, + each in canonical encoding; None for anything else.""" + items = values.get("x-codefly-hop-proof", []) + if len(items) != 1 or len(items[0]) > 8192: + return None + parts = items[0].split(".") + if len(parts) != 2: + return None + raw = [] + for value in parts: + if not re.fullmatch(r"[A-Za-z0-9_-]+", value): + return None + decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode() != value: + return None + raw.append(decoded) + return raw[0], raw[1] + + +def signed_by(headers, public_keys) -> str: + """Which published admission key (hex) the admission is signed by, or "" + for none: Go's stamp.SignedBy. ``check``, given that key, judges the rest.""" + proof = _proof(collect(headers)) + if proof is None: + return "" + for key in public_keys: + try: + Ed25519PublicKey.from_public_bytes(bytes.fromhex(key)).verify(proof[1], proof[0]) + return key + except Exception: + continue + return "" + + +@dataclass(frozen=True) +class Call: + nonce: str + expires: int + + +def check(headers, public_key: str, method: str, audience: str, now: float) -> Call: + values = collect(headers) + + def one(key): + items = values.get(key, []) + return items[0] if len(items) == 1 else "" + + try: + proof = _proof(values) + if proof is None: + raise ValueError("encoding") + payload, signature = proof + Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_key)).verify(signature, payload) + fields = json.loads(payload) + if not isinstance(fields, list) or len(fields) != 7: + raise ValueError("fields") + domain, bound_method, bound_audience, digest, nonce, issued, expires = fields + if any(not isinstance(v, str) for v in fields[:5]) or type(issued) is not int or type(expires) is not int: + raise ValueError("types") + except Exception as error: + raise Unauthenticated(Reason.STAMPPROOF) from error + if domain != "codefly-hop-v1": + raise Unauthenticated(Reason.STAMPPROOF) + if not method or method != bound_method: + raise Unauthenticated(Reason.STAMPMETHOD) + if not audience or audience != bound_audience: + raise Unauthenticated(Reason.STAMPAUDIENCE) + seconds = int(now) + if issued < 0 or expires < 0: + raise Unauthenticated(Reason.STAMPWINDOW) + if issued > seconds + SKEW: + raise Unauthenticated(Reason.STAMPISSUED) + if expires + SKEW <= seconds or not 0 < expires - issued <= LIFETIME: + raise Unauthenticated(Reason.STAMPWINDOW) + if not re.fullmatch(r"[0-9a-f]{64}", nonce): + raise Unauthenticated(Reason.STAMPPROOF) + tokens = values.get("x-codefly-work-context", []) + if len(tokens) != 1 or not tokens[0]: + raise Unauthenticated(Reason.STAMPTOKEN) + if digest != hashlib.sha256(tokens[0].encode()).hexdigest() or one("x-codefly-hop-context") != digest: + raise Unauthenticated(Reason.STAMPDIGEST) + if "x-codefly-hop-advisory" in values: + raise Unauthenticated(Reason.STAMPADVISORY) + return Call("codefly-hop-v1:" + public_key + ":" + nonce, expires) + + +class Expired(Exception): + """The reservation's retention had already passed when the store decided.""" + + +class ReservationStore(ReplayStore, Protocol): + """A store that holds a single-use admission while the call carrying it runs. + + The verifier reserves when the call arrives and finishes when its handler + has returned; the store keeps the reservation until ``finish`` AND + ``retain_until`` have both passed. ``reserve`` refuses atomically, across + every replica, a nonce already held (``Replayed``), including one held by a + call still running past its retention, and refuses a reservation whose + ``retain_until`` has already passed by the store's clock (``Expired``): a + reservation delayed past its own retention must never revive a nonce that + was collected. A ``finish`` that fails leaves the reservation held. + Because ``reserve`` refuses past ``retain_until`` on the store's clock, a + store may collect a reservation once that has passed, finished or not. + """ + def reserve(self, nonce: str, retain_until: int) -> None: ... + def finish(self, nonce: str) -> None: ... + async def reserve_async(self, nonce: str, retain_until: int) -> None: ... + async def finish_async(self, nonce: str) -> None: ... diff --git a/libraries/authority-client/python/tests/conftest.py b/libraries/authority-client/python/tests/conftest.py new file mode 100644 index 00000000..64a9abbc --- /dev/null +++ b/libraries/authority-client/python/tests/conftest.py @@ -0,0 +1,5 @@ +import pytest + +@pytest.fixture(autouse=True) +def local_environment(monkeypatch): + monkeypatch.setenv("CODEFLY__ENVIRONMENT", "local") diff --git a/libraries/authority-client/python/tests/test_hop.py b/libraries/authority-client/python/tests/test_hop.py index 574e0cdb..4745d1d1 100644 --- a/libraries/authority-client/python/tests/test_hop.py +++ b/libraries/authority-client/python/tests/test_hop.py @@ -1,5 +1,9 @@ import base64 import json +import hashlib +import secrets +import threading +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from pathlib import Path import pytest @@ -7,13 +11,37 @@ from authority_client._gen.host.authority.v1.authority_pb2 import KeysResponse from authority_client._gen.host.authority.v1.work_context_pb2 import WorkContextV1 +from codefly_hop.call import SKEW from codefly_hop import ( ACTORS_KEY, CONTEXT_KEY, KEY_RETRY, KEY_STALE, SCOPES_KEY, TENANT_KEY, WORK_CONTEXT_KEY, Identity, Unauthenticated, - Verifier, identity_of, + Verifier, Replayed, Expired, identity_of, ) FIXTURE = json.loads((Path(__file__).parents[3] / "hop/testdata/stamped.json").read_text()) +METHOD = "/test.Service/Read" + +class Replay: + def __init__(self): + self.seen, self.lock = set(), threading.Lock() + def consume(self, nonce, until): + with self.lock: + if nonce in self.seen: + raise Replayed() + self.seen.add(nonce) + async def consume_async(self, nonce, until): + self.consume(nonce, until) + +def fresh(now, *, method=METHOD, audience=FIXTURE["audience"], headers=None, issued=None, expires=None, hop=b"fixture-proof"): + headers = dict(FIXTURE["headers"] if headers is None else headers) + key = Ed25519PrivateKey.from_private_bytes(hashlib.sha256(hop).digest()) + payload = json.dumps(["codefly-hop-v1", method, audience, + hashlib.sha256(headers[WORK_CONTEXT_KEY].encode()).hexdigest(), secrets.token_hex(32), + int(now) if issued is None else issued, int(now)+30 if expires is None else expires], separators=(",", ":")).encode() + encode = lambda raw: base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + headers["x-codefly-hop-proof"] = encode(payload) + "." + encode(key.sign(payload)) + return headers + class Keys: """The authority interface's Keys, as the generated stub answers it.""" @@ -36,11 +64,11 @@ def published(): def verifier(clock=lambda: FIXTURE["now"], source=None): - return Verifier(source or Keys(published()), audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=clock) + return Verifier(source or Keys(published()), audience=FIXTURE["audience"], replay=Replay(), now=clock) def test_the_hop_fixture_authenticates_and_reads_as_go_reads_it(): - identity = verifier().verify(FIXTURE["headers"]) + identity = verifier().verify(FIXTURE["headers"], METHOD) want = FIXTURE["identity"] assert (identity.owner, identity.owner_kind, identity.tenant, identity.task, identity.expires) == ( want["owner"], want["owner_kind"], want["tenant"], want["task"], want["expires"]) @@ -61,7 +89,7 @@ def test_the_hop_fixture_authenticates_and_reads_as_go_reads_it(): ]) def test_a_stamp_the_token_does_not_bear_out_is_refused(change, reason): with pytest.raises(Unauthenticated, match=reason): - verifier().verify({**FIXTURE["headers"], **change}) + verifier().verify({**FIXTURE["headers"], **change}, METHOD) def test_a_token_the_authority_did_not_sign_is_refused_even_with_a_matching_digest(): @@ -69,15 +97,15 @@ def test_a_token_the_authority_did_not_sign_is_refused_even_with_a_matching_dige token = FIXTURE["headers"][WORK_CONTEXT_KEY] forged = token.split(".")[0] + "." + base64.urlsafe_b64encode(bytes(64)).rstrip(b"=").decode() headers = {**FIXTURE["headers"], WORK_CONTEXT_KEY: forged, CONTEXT_KEY: hashlib.sha256(forged.encode()).hexdigest()} - with pytest.raises(Unauthenticated, match="signature"): - verifier().verify(headers) + with pytest.raises(Unauthenticated, match="stamp_digest"): + verifier().verify(headers, METHOD) def test_another_audience_and_an_expired_context_are_refused(): - with pytest.raises(Unauthenticated, match="audience"): - Verifier(Keys(published()), audience="records", proof=FIXTURE["proof"], now=lambda: FIXTURE["now"]).verify(FIXTURE["headers"]) + with pytest.raises(Unauthenticated, match="stamp_audience"): + Verifier(Keys(published()), audience="records", replay=Replay(), now=lambda: FIXTURE["now"]).verify(FIXTURE["headers"], METHOD) with pytest.raises(Unauthenticated, match="expired"): - verifier(clock=lambda: FIXTURE["now"] + 3600, source=Keys(published())).verify(FIXTURE["headers"]) + verifier(clock=lambda: FIXTURE["now"] + 3600, source=Keys(published())).verify(FIXTURE["headers"], METHOD) def settle(v): @@ -92,24 +120,24 @@ def test_keys_are_reread_when_old_and_trusted_no_longer_than_stale(): clock = [FIXTURE["now"]] source = Keys(published()) v = verifier(clock=lambda: clock[0], source=source) - rotated = KeysResponse(issuer=published().issuer) + rotated = KeysResponse(issuer=published().issuer, admission_keys=published().admission_keys) rotated.keys.add(key_id="rotated", algorithm="Ed25519", public_key=bytes(32)) source.response = rotated source.hold = threading.Event() clock[0] += 400 # older than KEY_MAX_AGE: a re-read starts; the held key serves this call try: - v.verify(FIXTURE["headers"]) + v.verify(fresh(v._now()), METHOD) finally: source.hold.set() settle(v) assert source.reads == 2 clock[0] += 11 with pytest.raises(Unauthenticated, match="no verification key"): - v.verify(FIXTURE["headers"]) # the authority no longer publishes it + v.verify(fresh(v._now()), METHOD) # the authority no longer publishes it source.response, source.error = published(), RuntimeError("dial authority.internal:4000: connection refused") clock[0] += 1000 with pytest.raises(Unauthenticated, match="unavailable") as refused: - v.verify(FIXTURE["headers"]) # an unheld key waits for the read, which fails; the keys are stale + v.verify(fresh(v._now()), METHOD) # an unheld key waits for the read, which fails; the keys are stale assert "4000" not in str(refused.value) and "4000" in str(v.keys_error) @@ -123,7 +151,7 @@ def test_a_call_on_keys_past_stale_waits_for_the_reread(): source = Keys(published()) v = verifier(clock=lambda: clock[0], source=source) clock[0] = FIXTURE["now"] - assert v.verify(FIXTURE["headers"]).owner == FIXTURE["identity"]["owner"] + assert v.verify(fresh(v._now()), METHOD).owner == FIXTURE["identity"]["owner"] assert source.reads == 2 @@ -135,7 +163,7 @@ def test_a_call_on_keys_past_stale_is_refused_when_the_reread_fails(): import time as clock_time clock = [FIXTURE["now"] - KEY_STALE - 60] source = Keys(published()) - v = Verifier(source, audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=lambda: clock[0], timeout=0.3) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0], timeout=0.3) source.error, source.hold = RuntimeError("dial authority.internal:4000: connection refused"), threading.Event() clock[0] = FIXTURE["now"] outcome = [] @@ -158,7 +186,7 @@ def test_a_call_on_keys_past_stale_is_refused_when_the_reread_fails(): def _refusal(v): try: - v.verify(FIXTURE["headers"]) + v.verify(fresh(v._now()), METHOD) except Unauthenticated as refused: return str(refused) return "admitted" @@ -174,7 +202,7 @@ def test_a_call_without_the_proof_never_reads_the_keys(): clock[0] += 11 for headers in ({WORK_CONTEXT_KEY: "garbage"}, {**FIXTURE["headers"], "x-codefly-hop-proof": "another-run"}): with pytest.raises(Unauthenticated): - v.verify(headers) + v.verify(headers, METHOD) assert source.reads == 1 @@ -187,7 +215,7 @@ def test_a_hung_read_stalls_no_held_key(): source.hold = threading.Event() clock[0] += 400 # old keys: a background read starts and hangs started = clock_time.monotonic() - assert v.verify(FIXTURE["headers"]).owner == FIXTURE["identity"]["owner"] + assert v.verify(fresh(v._now()), METHOD).owner == FIXTURE["identity"]["owner"] assert clock_time.monotonic() - started < 1 source.hold.set() @@ -195,12 +223,12 @@ def test_a_hung_read_stalls_no_held_key(): def test_a_verifier_starts_before_the_authority_serves_keys(): clock = [FIXTURE["now"]] source = Keys(error=RuntimeError("connection refused")) - v = Verifier(source, audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=lambda: clock[0]) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0]) with pytest.raises(Unauthenticated, match="unavailable"): - v.verify(FIXTURE["headers"]) + v.verify(fresh(v._now()), METHOD) source.error, source.response = None, published() clock[0] += 11 - assert v.verify(FIXTURE["headers"]).owner == FIXTURE["identity"]["owner"] + assert v.verify(fresh(v._now()), METHOD).owner == FIXTURE["identity"]["owner"] def test_identity_narrows_by_every_actor_and_never_widens_a_resource_scope(): @@ -217,10 +245,10 @@ def test_identity_narrows_by_every_actor_and_never_widens_a_resource_scope(): assert identity_of(context).scopes == () # Core's EffectiveScopes: the last actor's grants -def test_a_verifier_needs_keys_an_audience_and_a_proof(): - for args in [(None, "documents", "p"), (Keys(published()), "", "p"), (Keys(published()), "documents", "")]: +def test_a_verifier_needs_keys_and_an_audience(): + for args in [(None, "documents"), (Keys(published()), "")]: with pytest.raises(Unauthenticated): - Verifier(args[0], audience=args[1], proof=args[2]) + Verifier(args[0], audience=args[1], replay=Replay()) # Review r57 m-b: an asyncio handler's wait for the keys never blocks the loop. @@ -230,7 +258,7 @@ def test_verify_async_waits_for_keys_off_the_event_loop(): clock = [FIXTURE["now"]] source = Keys(error=RuntimeError("not serving yet")) - v = Verifier(source, audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=lambda: clock[0], timeout=1.0) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0], timeout=1.0) source.error, source.response, source.hold = None, published(), threading.Event() clock[0] += 11 # no key held: this call starts a read, which hangs, and waits for it @@ -244,7 +272,7 @@ async def ticker(): ticks += 1 running = asyncio.create_task(ticker()) - waiting = asyncio.create_task(v.verify_async(FIXTURE["headers"])) + waiting = asyncio.create_task(v.verify_async(fresh(v._now()), METHOD)) await asyncio.sleep(0.3) source.hold.set() identity = await waiting @@ -271,13 +299,13 @@ def submit(self, *args, **kwargs): clock = [FIXTURE["now"] - KEY_STALE - 60] source = Keys(published()) - v = Verifier(source, audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=lambda: clock[0], timeout=1.0) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0], timeout=1.0) source.hold = threading.Event() clock[0] = FIXTURE["now"] async def scenario(): asyncio.get_running_loop().set_default_executor(NoThreads()) - calls = [asyncio.create_task(v.verify_async(FIXTURE["headers"])) for _ in range(64)] + calls = [asyncio.create_task(v.verify_async(fresh(v._now()), METHOD)) for _ in range(64)] await asyncio.sleep(0.1) assert not any(call.done() for call in calls), "a call was judged before its re-read ended" source.hold.set() @@ -285,7 +313,7 @@ async def scenario(): source.hold = threading.Event() # the next re-read hangs past the timeout clock[0] += KEY_STALE + KEY_RETRY # stale keys are refused before the token's own expiry is read try: - await v.verify_async(FIXTURE["headers"]) + await v.verify_async(fresh(v._now()), METHOD) except Unauthenticated as refused: return identities, str(refused) return identities, "admitted" @@ -316,7 +344,7 @@ def test_ready_holds_while_keys_are_trusted(): def test_an_unready_replica_becomes_ready_by_polling_alone(): clock = [FIXTURE["now"]] source = Keys(error=RuntimeError("not serving yet")) - v = Verifier(source, audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=lambda: clock[0]) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0]) ready = False for poll in range(30): if poll == 3: @@ -344,12 +372,119 @@ def test_an_idle_replica_polled_for_readiness_stays_ready(): assert 10 <= source.reads <= 14 +# Independent review probes: method retargeting, cross-audience use, replay on +# another replica, and a workload constructing the old shared-proof stamp. +def test_review_retargeting_and_replay_across_replicas(): + store = Replay() + first = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, now=lambda: FIXTURE["now"]) + second = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, now=lambda: FIXTURE["now"]) + headers = FIXTURE["headers"] + for method in ("/documents.v1.Service/Delete", "/datasets.v1.Service/Register", "Read", "", "POST " + METHOD): + with pytest.raises(Unauthenticated, match="stamp_method"): + first.verify(headers, method) + other = Verifier(Keys(published()), audience="records", replay=store, now=lambda: FIXTURE["now"]) + with pytest.raises(Unauthenticated, match="stamp_audience"): + other.verify(headers, METHOD) + assert first.verify(headers, METHOD).claims.context.replay_policy == "single-use" + for replica in (first, second): + with pytest.raises(Unauthenticated, match="stamp_replayed"): + replica.verify(headers, METHOD) + with pytest.raises(Unauthenticated, match="proof"): + second.verify({**headers, "x-codefly-hop-proof": "fixture-proof"}, METHOD) + + +def test_review_concurrent_replay_and_store_failure(): + from concurrent.futures import ThreadPoolExecutor + v = verifier() + def attempt(_): + try: + v.verify(FIXTURE["headers"], METHOD) + return 1 + except Unauthenticated: + return 0 + with ThreadPoolExecutor(max_workers=16) as pool: + assert sum(pool.map(attempt, range(32))) == 1 + class Down(Replay): + def consume(self, nonce, until): + raise OSError("store down") + v._replay = Down() + with pytest.raises(Unauthenticated, match="stamp_store"): + v.verify(fresh(FIXTURE["now"]), METHOD) + + +@pytest.mark.parametrize("issued,expires,code", [ + ((0, 30, "stamp_window")), + # Issued further ahead than the clock allowance: its own reason, so a + # verifier running behind is not diagnosed as an expiry. + ((FIXTURE["now"] + SKEW + 1, FIXTURE["now"] + 30, "stamp_issued")), + # The allowance widens no span: 31 seconds is refused however clocks sit. + ((FIXTURE["now"], FIXTURE["now"] + 31, "stamp_window")), +]) +def test_signed_admission_window_is_bounded(issued, expires, code): + with pytest.raises(Unauthenticated) as refused: + verifier().verify(fresh(FIXTURE["now"], issued=issued, expires=expires), METHOD) + assert refused.value.code == code + + +# Review r164 R1: a verifier whose clock lags the hop refused genuine calls +# whenever the signing instant fell just after a second boundary, under a reason +# that read as an expiry. Go, Python and TypeScript allow the same SKEW. +@pytest.mark.parametrize("lag", [0, 1, SKEW]) +def test_a_clock_difference_inside_the_allowance_still_admits(lag): + identity = verifier(clock=lambda: FIXTURE["now"] - lag).verify(fresh(FIXTURE["now"]), METHOD) + assert identity.owner == FIXTURE["identity"]["owner"] + + +@pytest.mark.parametrize("lag,code", [(SKEW + 1, "stamp_issued"), (-(30 + SKEW), "stamp_window")]) +def test_a_clock_difference_beyond_the_allowance_is_refused(lag, code): + with pytest.raises(Unauthenticated) as refused: + verifier(clock=lambda: FIXTURE["now"] - lag).verify(fresh(FIXTURE["now"]), METHOD) + assert refused.value.code == code + + +# Review r164 R2: the reservation outlives the admission by the same allowance, +# so a store evicting on its own clock cannot release a nonce a lagging verifier +# would still admit. +def test_a_reservation_is_retained_past_expiry_by_the_allowance(): + retained = [] + + class Recording: + def consume(self, nonce, retain_until): + retained.append(retain_until) + + v = verifier() + v._replay = Recording() + headers = fresh(FIXTURE["now"]) + payload = json.loads(base64.urlsafe_b64decode( + headers["x-codefly-hop-proof"].split(".")[0] + "=" * (-len(headers["x-codefly-hop-proof"].split(".")[0]) % 4))) + v.verify(headers, METHOD) + assert retained == [payload[6] + SKEW] + + +def test_duplicate_proof_and_token_are_refused_before_consumption(): + for key in ("x-codefly-hop-proof", WORK_CONTEXT_KEY, CONTEXT_KEY): + v = verifier() + with pytest.raises(Unauthenticated): + v.verify(list(FIXTURE["headers"].items()) + [(key, FIXTURE["headers"][key])], METHOD) + v.verify(FIXTURE["headers"], METHOD) + + +def test_async_replay_has_one_winner_across_replicas(): + import asyncio + store = Replay() + replicas = [Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, now=lambda: FIXTURE["now"]) for _ in range(2)] + async def run(): + return await asyncio.gather(*(replicas[n % 2].verify_async(FIXTURE["headers"], METHOD) for n in range(32)), return_exceptions=True) + results = asyncio.run(run()) + assert sum(isinstance(result, Identity) for result in results) == 1 + assert sum(isinstance(result, Unauthenticated) for result in results) == 31 + @pytest.mark.parametrize("row", json.loads((Path(__file__).parents[2] / "testdata/vectors.json").read_text()), ids=lambda r: r["name"]) def test_bare_token_matches_shared_verdict(row): response = KeysResponse(issuer=row["issuer"]) for kid, raw in row["keys"].items(): response.keys.add(key_id=kid, algorithm="Ed25519", public_key=base64.b64decode(raw)) - v = Verifier(Keys(response), audience=row["audience"], proof="proof", now=lambda: row["now"]) + v = Verifier(Keys(response), audience=row["audience"], now=lambda: row["now"]) if row["accept"]: assert v.verify_token(row["token"]).claims.audience == row["audience"] else: @@ -387,16 +522,17 @@ def test_bare_token_stale_wait_async_and_safe_reasons(): ]) def test_stamp_failure_codes_are_safe_and_distinct(change, code): with pytest.raises(Unauthenticated) as refused: - verifier().verify({**FIXTURE["headers"], **change}) + verifier().verify({**FIXTURE["headers"], **change}, METHOD) assert refused.value.code == code assert "private-" not in str(refused.value) -def test_bare_only_process_needs_no_proof_and_cannot_accept_stamps(): +def test_bare_only_process_needs_no_admission_keys_and_cannot_accept_stamps(): v = Verifier(Keys(published()), audience=FIXTURE["audience"], now=lambda: FIXTURE["now"]) assert v.verify_token(FIXTURE["headers"][WORK_CONTEXT_KEY]).owner == FIXTURE["identity"]["owner"] - with pytest.raises(Unauthenticated): - v.verify(FIXTURE["headers"]) + # Without the hop public key and a shared replay store it admits nothing. + with pytest.raises(Unauthenticated, match="configuration"): + v.verify(FIXTURE["headers"], METHOD) def test_invalid_publication_and_unreadable_keys_have_distinct_safe_codes(): @@ -407,3 +543,302 @@ def test_invalid_publication_and_unreadable_keys_have_distinct_safe_codes(): v.verify_token(FIXTURE["headers"][WORK_CONTEXT_KEY]) assert refused.value.code == code assert "private-" not in str(refused.value) + + +class Reservations: + """A reservation store as replaypg is: a nonce is held until it is finished + AND its retention has passed, and a reservation past its retention is + refused rather than inserted. ``collect`` removes what has aged out.""" + + shared = True + + def __init__(self, clock): + self.clock, self.held, self.active = clock, {}, set() + + def reserve(self, nonce, until): + if until <= self.clock(): + raise Expired() + if nonce in self.held: + raise Replayed() + self.held[nonce] = until + self.active.add(nonce) + + def finish(self, nonce): + self.active.discard(nonce) + + async def reserve_async(self, nonce, until): + self.reserve(nonce, until) + + async def finish_async(self, nonce): + self.finish(nonce) + + def consume(self, nonce, until): + raise AssertionError("a reservation store is reserved, never consumed") + + async def consume_async(self, nonce, until): + self.consume(nonce, until) + + def collect(self): + for nonce, until in list(self.held.items()): + if nonce not in self.active and until < self.clock(): + del self.held[nonce] + + +# Review of #28 at 8a3e953b: a single-use admission is consumed exactly once. +# Two deliveries of one admission arrive inside its window; the first holds its +# reservation while its handler runs, past its retention and collection; the +# second is refused by name, and so is a delivery after the handler returned. +def test_a_second_delivery_of_one_admission_is_refused_while_and_after_the_first_runs(): + clock = [FIXTURE["now"]] + store = Reservations(lambda: clock[0]) + v = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, + now=lambda: clock[0]) + headers = fresh(FIXTURE["now"]) + ran = 0 + with v.admit(headers, METHOD) as identity: + ran += 1 + assert identity.owner == FIXTURE["identity"]["owner"] + with pytest.raises(Unauthenticated, match="stamp_replayed"): + v.verify(headers, METHOD) + clock[0] += 30 + SKEW + 5 # past the retention while the handler still runs + store.collect() + assert store.held, "collection removed the reservation of a running call" + with pytest.raises(Unauthenticated) as refused: + with v.admit(headers, METHOD): + ran += 1 + assert refused.value.code == "stamp_window" + store.collect() + assert not store.held and ran == 1 + + +# The reservation is taken before the Work Context is authenticated, so a +# refusal there ends the hold rather than leaving it pinned, and a second +# delivery still meets the reservation. An admission that cannot be judged, +# because no keys are held, reserves nothing. +def test_the_reservation_is_taken_before_the_work_context_and_released_on_refusal(): + clock = [FIXTURE["now"]] + store = Reservations(lambda: clock[0]) + v = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, + now=lambda: clock[0]) + token = FIXTURE["headers"][WORK_CONTEXT_KEY] + forged = token.split(".")[0] + "." + base64.urlsafe_b64encode(bytes(64)).rstrip(b"=").decode() + headers = fresh(FIXTURE["now"], headers={**FIXTURE["headers"], WORK_CONTEXT_KEY: forged, + CONTEXT_KEY: hashlib.sha256(forged.encode()).hexdigest()}) + with pytest.raises(Unauthenticated, match="signature"): + v.verify(headers, METHOD) + assert list(store.held) and not store.active, "a refused call kept its hold, or never reserved" + with pytest.raises(Unauthenticated, match="stamp_replayed"): + v.verify(headers, METHOD) + unread = Reservations(lambda: clock[0]) + down = Verifier(Keys(error=RuntimeError("authority down")), audience=FIXTURE["audience"], replay=unread, + now=lambda: clock[0], timeout=0.05) + with pytest.raises(Unauthenticated, match="keys"): + down.verify(fresh(FIXTURE["now"]), METHOD) + assert not unread.held, "an admission judged on no keys was reserved" + + +def test_admit_releases_its_hold_however_the_handler_ends(): + clock = [FIXTURE["now"]] + store = Reservations(lambda: clock[0]) + v = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, + now=lambda: clock[0]) + with pytest.raises(ZeroDivisionError): + with v.admit(fresh(FIXTURE["now"]), METHOD): + assert store.active + 1 / 0 + assert store.held and not store.active + + +def test_admit_async_holds_the_reservation_until_the_handler_returns(): + import asyncio + clock = [FIXTURE["now"]] + store = Reservations(lambda: clock[0]) + v = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, + now=lambda: clock[0]) + headers = fresh(FIXTURE["now"]) + + async def run(): + async with v.admit_async(headers, METHOD): + assert store.active + with pytest.raises(Unauthenticated, match="stamp_replayed"): + await v.verify_async(headers, METHOD) + assert not store.active + asyncio.run(run()) + + +# A store without reserve keeps a nonce to its retention and may then evict it. +# A delivery that arrived inside the window, but whose consumption completed +# after the retention, found an evicted nonce: it is refused on the window. +def test_a_consumption_completing_past_its_retention_is_refused(monkeypatch): + import time as clock_module + real, slow = clock_module.monotonic, [0] + monkeypatch.setattr(clock_module, "monotonic", lambda: real() + slow[0]) + + class Evicted: + """Every nonce has been evicted; the second consumption takes a minute.""" + def __init__(self): + self.calls = 0 + def consume(self, nonce, until): + self.calls += 1 + slow[0] = 60 if self.calls == 2 else slow[0] + + v = verifier() + v._replay = Evicted() + v.verify(fresh(FIXTURE["now"]), METHOD) + with pytest.raises(Unauthenticated) as refused: + v.verify(fresh(FIXTURE["now"]), METHOD) + assert refused.value.code == "stamp_window" + + +# Outside the run named exactly "local", a store that does not declare itself +# the durable one every replica shares is refused at startup, as Go refuses it. +@pytest.mark.parametrize("environment", [None, "", "production", "staging", "local-production", "local-docker", " local", "local ", "local"]) +def test_startup_requires_a_shared_store_outside_exactly_local(monkeypatch, environment): + if environment is None: + monkeypatch.delenv("CODEFLY__ENVIRONMENT", raising=False) + else: + monkeypatch.setenv("CODEFLY__ENVIRONMENT", environment) + if environment == "local": + verifier() + else: + with pytest.raises(Unauthenticated) as refused: + verifier() + assert refused.value.code == "replay_store_not_shared" + store = Replay() + store.shared = True + Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store) + Verifier(Keys(published()), audience=FIXTURE["audience"]) # a token-only verifier holds no store + + +def hop_key(label: bytes): + """The admission key of the test hop that signs as label, as Keys publishes it.""" + from authority_client._gen.host.authority.v1.authority_pb2 import PublicKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + raw = Ed25519PrivateKey.from_private_bytes(hashlib.sha256(label).digest()).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + return PublicKey(key_id="hop-" + label.decode(), algorithm="Ed25519", public_key=raw, purpose="hop-admission") + + +def publishing(*labels: bytes) -> KeysResponse: + """The fixture's Work Context keys, and the admission keys of these hops.""" + response = published() + del response.admission_keys[:] + response.admission_keys.extend(hop_key(label) for label in labels) + return response + + +# Review r3 B1: the hop's admission key comes from the authority's Keys, the +# read the verifier already makes; nothing about the hop is configured. +def test_a_verifier_takes_the_hops_key_from_the_authoritys_keys_alone(): + v = Verifier(Keys(publishing(b"the-hop")), audience=FIXTURE["audience"], replay=Replay(), now=lambda: FIXTURE["now"]) + assert v.verify(fresh(FIXTURE["now"], hop=b"the-hop"), METHOD).owner == FIXTURE["identity"]["owner"] + + +# A stamp signed by a key the authority does not publish is refused by name, +# and causes no read of the authority's keys. +def test_a_stamp_signed_by_an_unpublished_key_is_refused_by_name(): + source = Keys(publishing(b"the-hop")) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: FIXTURE["now"]) + settle(v) + reads = source.reads + with pytest.raises(Unauthenticated) as refused: + v.verify(fresh(FIXTURE["now"], hop=b"an-unpublished-hop"), METHOD) + assert refused.value.code == "stamp_proof" + settle(v) + assert source.reads == reads + + +# While a hop's key rotates both are published and admitted; once the retiring +# key is withdrawn, the next read stops admitting it. +def test_a_rotations_overlap_admits_both_keys_until_the_retiring_one_is_withdrawn(): + clock = [FIXTURE["now"]] + source = Keys(publishing(b"retiring", b"current")) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0]) + for hop in (b"retiring", b"current"): + v.verify(fresh(clock[0], hop=hop), METHOD) + source.response = publishing(b"current") + clock[0] += 301 # past KEY_MAX_AGE: readiness re-reads + v.ready() + settle(v) + with pytest.raises(Unauthenticated) as refused: + v.verify(fresh(clock[0], hop=b"retiring"), METHOD) + assert refused.value.code == "stamp_proof" + v.verify(fresh(clock[0], hop=b"current"), METHOD) + + +# Each key serves its own purpose only: an admission key published among the +# Work Context keys admits nothing, and an admission key never signs a Work +# Context. +def test_a_key_is_used_for_its_own_purpose_only(): + from authority_client.work_context import Trust + response = publishing() + response.keys.append(hop_key(b"misplaced")) + v = Verifier(Keys(response), audience=FIXTURE["audience"], replay=Replay(), now=lambda: FIXTURE["now"]) + with pytest.raises(Unauthenticated) as refused: + v.verify(fresh(FIXTURE["now"], hop=b"misplaced"), METHOD) + assert refused.value.code == "keys_unavailable" # no admission key is published at all + assert "hop-misplaced" not in Trust.from_keys(response).keys + + +# An authority that publishes no admission key admits nothing, under a fixed +# reason, and says why in the module's logs. +def test_no_published_admission_key_refuses_every_call_and_says_why(): + from codefly_hop import NoAdmissionKeys + v = Verifier(Keys(publishing()), audience=FIXTURE["audience"], replay=Replay(), now=lambda: FIXTURE["now"]) + with pytest.raises(Unauthenticated) as refused: + v.verify(fresh(FIXTURE["now"]), METHOD) + assert refused.value.code == "keys_unavailable" + assert isinstance(v.keys_error, NoAdmissionKeys) + + +# Review r5: a verifier that first read the keys before the hop registered its +# key holds no admission key; once the authority publishes one, the next call +# or readiness check after KEY_RETRY reads it, without waiting out KEY_MAX_AGE, +# and callers inside KEY_RETRY drive no read. +def test_a_verifier_that_read_before_the_hop_registered_learns_its_key_within_key_retry(): + clock = [FIXTURE["now"]] + source = Keys(publishing()) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0]) + with pytest.raises(Unauthenticated) as refused: + v.verify(fresh(clock[0], hop=b"the-hop"), METHOD) + assert refused.value.code == "keys_unavailable" + settle(v) + reads = source.reads + for _ in range(5): + with pytest.raises(Unauthenticated): + v.verify(fresh(clock[0], hop=b"the-hop"), METHOD) + settle(v) + assert source.reads == reads + source.response = publishing(b"the-hop") + clock[0] += KEY_RETRY + assert v.verify(fresh(clock[0], hop=b"the-hop"), METHOD).owner == FIXTURE["identity"]["owner"] + idle_source = Keys(publishing()) + idle = Verifier(idle_source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0]) + idle.ready() + settle(idle) + idle_source.response = publishing(b"the-hop") + clock[0] += KEY_RETRY + idle.ready() + settle(idle) + assert idle.keys_error is None + + +# Review r3 M3: a store with reserve and no finish is not a reservation store +# (TypeScript's rule): it is consumed, so no reservation is left that nothing +# finishes. +class HalfReservations(Replay): + def __init__(self): + super().__init__() + self.reserved = [] + def reserve(self, nonce, until): + self.reserved.append(nonce) + async def reserve_async(self, nonce, until): + self.reserved.append(nonce) + + +def test_a_store_with_reserve_but_no_finish_is_consumed_not_reserved(): + import asyncio + store = HalfReservations() + v = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, now=lambda: FIXTURE["now"]) + v.verify(fresh(FIXTURE["now"]), METHOD) + asyncio.run(v.verify_async(fresh(FIXTURE["now"]), METHOD)) + assert not store.reserved and len(store.seen) == 2 diff --git a/libraries/authority-client/python/tests/test_owner.py b/libraries/authority-client/python/tests/test_owner.py index 202552bf..0bb1b8cd 100644 --- a/libraries/authority-client/python/tests/test_owner.py +++ b/libraries/authority-client/python/tests/test_owner.py @@ -1,7 +1,7 @@ """The solution principal (conformance RunSolutionPrincipalTarget), read in Python. A module served in Python never reads an owner-selected scope as the whole -kind. Claims are built through ``authenticate`` from a signed context, so the +kind. Claims are built through ``authenticate_token`` from a signed context, so the decode path keeps the owner: the owner-blind ``allows`` refuses an owner-selected scope, ``allows_owned`` admits a resource only for its owner, the hop's identity leaves owner-selected scopes out, and a chain whose hop @@ -13,14 +13,14 @@ import pytest from authority_client._gen.host.authority.v1.work_context_pb2 import WorkContextV1 -from authority_client.work_context import Unauthenticated, authenticate +from authority_client.work_context import Unauthenticated, authenticate_token from codefly_hop import identity_of from test_work_context import PROOF, body, sign, stamped, trust_for def claims_of(context): - return authenticate(stamped(sign(context)), PROOF, context.audience, trust_for(context)) + return authenticate_token(sign(context), context.audience, trust_for(context)) # The owner-selected rows (allows refusing, allows_owned admitting only the diff --git a/libraries/authority-client/python/tests/test_work_context.py b/libraries/authority-client/python/tests/test_work_context.py index 623e8911..58be17c2 100644 --- a/libraries/authority-client/python/tests/test_work_context.py +++ b/libraries/authority-client/python/tests/test_work_context.py @@ -12,7 +12,7 @@ from authority_client._gen.host.authority.v1.authority_pb2 import KeysResponse, PublicKey from authority_client._gen.host.authority.v1.work_context_pb2 import WorkContextV1 from authority_client.work_context import ( - CONTEXT_KEY, PROOF_KEY, WORK_CONTEXT_KEY, Claims, Scope, Trust, Unauthenticated, authenticate, + CONTEXT_KEY, PROOF_KEY, WORK_CONTEXT_KEY, Claims, Scope, Trust, Unauthenticated, authenticate_token, bound_token, ) FIXTURES = json.loads((Path(__file__).parents[2] / "testdata/claims.json").read_text()) @@ -49,7 +49,7 @@ def stamped(token, proof=PROOF): def read(context): - return authenticate(stamped(sign(context)), PROOF, context.audience, trust_for(context)) + return authenticate_token(sign(context), context.audience, trust_for(context)) @pytest.mark.parametrize("fixture", FIXTURES, ids=lambda f: f["name"]) @@ -110,7 +110,7 @@ def test_a_forged_or_misaddressed_context_is_refused_by_name(): (sign(context), "", trust, "configuration"), ]: with pytest.raises(Unauthenticated, match=reason): - authenticate(stamped(token), PROOF, audience, use) + authenticate_token(token, audience, use) @pytest.mark.parametrize("token", ["", "abc", "a.b.c", ".", "a." + encoded(bytes(64)), "é.x", @@ -118,22 +118,22 @@ def test_a_forged_or_misaddressed_context_is_refused_by_name(): def test_unreadable_envelopes_are_refused(token): context = body("session") with pytest.raises(Unauthenticated): - authenticate(stamped(token), PROOF, context.audience, trust_for(context)) + authenticate_token(token, context.audience, trust_for(context)) -@pytest.mark.parametrize("change", ["proof", "digest", "token", "duplicate", "missing", "empty proof"]) +@pytest.mark.parametrize("change", ["digest", "token", "duplicate", "missing"]) def test_the_reader_refuses_a_token_the_stamp_does_not_bind(change): context = body("session") token = sign(context) headers, proof = stamped(token), PROOF - if change == "proof": headers[PROOF_KEY] = "forged" if change == "digest": headers[CONTEXT_KEY] = "0" * 64 if change == "token": headers[WORK_CONTEXT_KEY] = sign(body("operation")) if change == "duplicate": headers = list(headers.items()) + [(WORK_CONTEXT_KEY, token)] if change == "missing": headers.pop(CONTEXT_KEY) - if change == "empty proof": proof = "" + # bound_token refuses the binding; authenticate_token then the token. The + # two are separate calls because neither admits a call (review r164 R4). with pytest.raises(Unauthenticated): - authenticate(headers, proof, context.audience, trust_for(context)) + authenticate_token(bound_token(headers), context.audience, trust_for(context)) def test_trust_comes_only_from_published_ed25519_keys(): @@ -156,10 +156,11 @@ def test_trust_comes_only_from_published_ed25519_keys(): def test_python_reaches_gos_verdict_on_every_shared_vector(vector): keys = {kid: base64.b64decode(raw) for kid, raw in vector["keys"].items()} trust = Trust(vector["issuer"], keys, now=lambda: datetime.fromtimestamp(vector["now"], timezone.utc)) - headers = stamped(vector["token"]) + # The same verdict reached through the binding a hop writes. + bound = bound_token(stamped(vector["token"])) if vector["accept"]: - authenticate(headers, PROOF, vector["audience"], trust) + authenticate_token(bound, vector["audience"], trust) else: with pytest.raises(Unauthenticated) as refused: - authenticate(headers, PROOF, vector["audience"], trust) + authenticate_token(bound, vector["audience"], trust) assert vector["reason"] in str(refused.value) diff --git a/libraries/authority-client/typescript/README.md b/libraries/authority-client/typescript/README.md index 68f41a72..9c393a7d 100644 --- a/libraries/authority-client/typescript/README.md +++ b/libraries/authority-client/typescript/README.md @@ -8,16 +8,19 @@ depends on: ```ts import { Unauthenticated, Verifier } from '@codefly-dev/authority-client/work-context'; -const verifier = new Verifier(createClient(AuthorityService, transport), audience, proof); -const id = await verifier.verify(requestHeaders); // throws Unauthenticated, naming why +const verifier = new Verifier(createClient(AuthorityService, transport), audience, sharedReplayStore); // the hop's key comes from Keys +const id = await verifier.verify(requestHeaders, fullProcedure); // throws Unauthenticated, naming why +await verifier.admit(requestHeaders, fullProcedure, async id => handle(id)); // holds the single-use reservation while handle runs id.tenant; id.scopes; id.claims.allows(resourceKind, action, exactResourceID); ``` -It is the Go library's `hop.Verifier`: the stamp first (proof and digest), then +It is the Go library's `hop.Verifier`: the signed method, audience, token digest and admission window first, then the keys read through `Keys` (re-read on an unknown key at most every 10 s and when older than 5 minutes, on a read of its own bounded by 5 s; stale after 15 minutes, when a call waits for the re-read as it does on an unknown key), then -`authenticateClaims`: Core's decode path (size, the contract's `buf.validate` +`authenticateToken`, the one identity reader (a caller holding headers extracts +the bound token with `boundToken` first): Core's decode path (size, the +contract's `buf.validate` rules run by protovalidate, unknown fields, canonical encoding, lifetime), the issuer, the key (never Core's public conformance key), the Ed25519 signature, the audience, the window, and attenuation and grant shape. Who the call is for @@ -28,7 +31,11 @@ caller. **Readiness uses `verifier.ready()`**: true while trusted keys are held, through failed re-reads inside the 15-minute window; false before the first read or once they are stale. It also starts the same rate-limited, non-blocking re-read a call would, so an unready replica recovers and an idle one never goes -stale. Never gate readiness on `keysError`. Do not send the proof to a browser. +stale. Never gate readiness on `keysError`. Before reading the keys, it reserves +the nonce through the required durable replay store shared across replicas, and +outside the run named exactly `local` the constructor refuses a store whose +`shared` is not true (`replay_store_not_shared`). See the [admission +protocol](../../hop/README.md). `claims.scopes` contains frozen copies of the final actor's grants, or the owner's when no actor exists. Each scope is evaluated independently; an omitted resource @@ -58,7 +65,14 @@ values. These errors never retain underlying key-source errors or claim values. `KeysError` / `keys_error` / `keysError` is a separate internal diagnostic, not a caller response. Unknown future Core diagnostic categories remain refused. -Processes that receive only Issue responses use Go `NewTokenVerifier` or omit -`proof` when constructing Python/TypeScript `Verifier`. They still authenticate -bare tokens against published Keys; stamped verification is refused without a -configured run proof. A process receiving hop calls continues to require it. +Admission refusals carry their own codes, in the same scheme: `stamp_proof` (no +admission signed for this call), `stamp_token`, `stamp_digest`, `stamp_method`, +`stamp_audience`, `stamp_window`, `stamp_advisory`, `stamp_replayed` and +`stamp_store`. Each names no proof, token, digest, nonce or claim. + +Processes that receive only Issue responses use Go `NewTokenVerifier`, or omit +`replay` when constructing the Python/TypeScript `Verifier`. They still +authenticate bare tokens against published Keys. They admit no stamped call: +without a shared replay store an admission cannot be consumed once, so `verify` +refuses with `configuration`. A process that receives hop calls supplies one; +the hop's admission keys come from the same Keys read, never from configuration. diff --git a/libraries/authority-client/typescript/hop-call.ts b/libraries/authority-client/typescript/hop-call.ts new file mode 100644 index 00000000..b272bea0 --- /dev/null +++ b/libraries/authority-client/typescript/hop-call.ts @@ -0,0 +1,103 @@ +import { createHash, createPublicKey, verify } from "node:crypto"; +import type { KeysResponse } from "./src/gen/host/authority/v1/authority_pb.js"; +import { Reason, Unauthenticated } from "./refusal.js"; + +/** What a published admission key verifies: Keys carries the hop's keys in + * admission_keys with this purpose, never among the Work Context keys. */ +export const PURPOSE = "hop-admission"; + +/** The hop admission keys a KeysResponse publishes, hex: Ed25519 keys in + * admissionKeys with purpose PURPOSE; anything else there is not one. */ +export function admissionKeys(response: KeysResponse): string[] { + const out: string[] = []; + for (const key of response.admissionKeys) { + if (key.algorithm !== "Ed25519" || key.purpose !== PURPOSE) continue; + if (key.publicKey.length !== 32) throw new Unauthenticated(Reason.InvalidKey); + const hex = Buffer.from(key.publicKey).toString("hex"); + if (!out.includes(hex)) out.push(hex); + } + return out; +} + +/** The one admission the headers carry, as its signed payload and signature, + * each in canonical encoding; undefined for anything else. */ +function proof(pairs: readonly (readonly [string, string])[]): { payload: Buffer; signature: Buffer } | undefined { + const values = pairs.filter(([k]) => k === "x-codefly-hop-proof"); + if (values.length !== 1 || values[0][1].length > 8192) return undefined; + const parts = values[0][1].split("."); + if (parts.length !== 2) return undefined; + const raw = parts.map(part => /^[A-Za-z0-9_-]+$/.test(part) ? Buffer.from(part, "base64url") : undefined); + if (raw.some((r, i) => !r || r.toString("base64url") !== parts[i])) return undefined; + return { payload: raw[0]!, signature: raw[1]! }; +} + +const ed25519 = (hex: string) => createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(hex, "hex").toString("base64url") }, format: "jwk" }); + +/** Which published admission key (hex) the admission is signed by, or "" for + * none: Go's stamp.SignedBy. checkCall, given that key, judges the rest. */ +export function signedBy(pairs: readonly (readonly [string, string])[], publicKeys: readonly string[]): string { + const signed = proof(pairs); + if (!signed) return ""; + for (const key of publicKeys) { + if (/^[0-9a-f]{64}$/.test(key) && verify(null, signed.payload, ed25519(key), signed.signature)) return key; + } + return ""; +} + +/** The hop admission lifetime and the clock allowance, as Go's stamp.Lifetime + * and stamp.Skew define them, in seconds. An admission may be issued up to SKEW + * ahead of this verifier's clock and stays admissible until SKEW past its + * expiry; the signed span is still capped at LIFETIME exactly. */ +export const LIFETIME = 30, SKEW = 2; + +/** Durable atomic consumption, shared by all replicas. Throw Replayed on reuse, another error on outage. + * retainUntil is Unix seconds. Successful reservations must survive restart. */ +export class Replayed extends Error { override name = "Replayed"; } + +/** The reservation's retention had already passed when the store decided. */ +export class Expired extends Error { override name = "Expired"; } + +export interface ReplayStore { + /** True only for the durable store every replica shares; required outside local. */ + readonly shared?: boolean; + consume(nonce: string, retainUntil: number): Promise; + /** A reservation store holds a single-use admission while the call carrying + * it runs: the verifier reserves when the call arrives and finishes when its + * handler has returned, and the store keeps the reservation until finish AND + * retainUntil have both passed. reserve refuses atomically, across every + * replica, a nonce already held (Replayed), including one held by a call still + * running past its retention, and refuses a reservation whose retainUntil has + * already passed by the store's clock (Expired), so a delayed reservation + * never revives a collected nonce. A finish that fails leaves it held. + * Because reserve refuses past retainUntil on the store's clock, a store may + * collect a reservation once that has passed, finished or not. */ + reserve?(nonce: string, retainUntil: number): Promise; + finish?(nonce: string): Promise; +} + +export function checkCall(pairs: readonly (readonly [string, string])[], publicKey: string, method: string, audience: string, now: number): { nonce: string; expires: number } { + const fail = (reason: Reason): never => { throw new Unauthenticated(reason); }; + const one = (key: string): string => { const values = pairs.filter(([k]) => k === key); return values.length === 1 ? values[0][1] : ""; }; + const signed = proof(pairs); + if (!/^[0-9a-f]{64}$/.test(publicKey) || !signed || !verify(null, signed.payload, ed25519(publicKey), signed.signature)) return fail(Reason.StampProof); + const { payload } = signed; + let fields: unknown; + try { fields = JSON.parse(payload.toString("utf8")); } catch { fail(Reason.StampProof); } + if (!Array.isArray(fields) || fields.length !== 7) return fail(Reason.StampProof); + const [domain, boundMethod, boundAudience, digest, nonce, issued, expires] = fields; + if (!fields.slice(0, 5).every(v => typeof v === "string") || !Number.isSafeInteger(issued) || !Number.isSafeInteger(expires)) fail(Reason.StampProof); + if (domain !== "codefly-hop-v1") fail(Reason.StampProof); + if (!method || method !== boundMethod) fail(Reason.StampMethod); + if (!audience || audience !== boundAudience) fail(Reason.StampAudience); + const seconds = Math.floor(now / 1000); + if (issued < 0 || expires < 0) fail(Reason.StampWindow); + if (issued > seconds + SKEW) fail(Reason.StampIssued); + if (expires + SKEW <= seconds || expires <= issued || expires - issued > LIFETIME) fail(Reason.StampWindow); + if (!/^[0-9a-f]{64}$/.test(nonce)) fail(Reason.StampProof); + const tokens = pairs.filter(([k]) => k === "x-codefly-work-context"); + if (tokens.length !== 1 || !tokens[0][1]) fail(Reason.StampToken); + const token = tokens[0][1]; + if (digest !== createHash("sha256").update(token).digest("hex") || digest !== one("x-codefly-hop-context")) fail(Reason.StampDigest); + if (pairs.some(([key]) => key === "x-codefly-hop-advisory")) fail(Reason.StampAdvisory); + return { nonce: `codefly-hop-v1:${publicKey}:${nonce}`, expires }; +} diff --git a/libraries/authority-client/typescript/refusal.ts b/libraries/authority-client/typescript/refusal.ts new file mode 100644 index 00000000..86b85d6e --- /dev/null +++ b/libraries/authority-client/typescript/refusal.ts @@ -0,0 +1,73 @@ +/** Caller-safe verifier failures, as Go's libraries/authority-client/go/refusal + * and Python's authority_client.work_context.Reason define them. No reason + * retains a token, claim, key, transport error or underlying message. + * + * This lives apart from work-context.ts so hop-call.ts can refuse with the same + * codes without importing it back. */ +export enum Reason { + Configuration = "configuration", + StampProof = "stamp_proof", + StampToken = "stamp_token", + StampDigest = "stamp_digest", + StampIdentity = "stamp_identity", + StampMethod = "stamp_method", + StampAudience = "stamp_audience", + StampWindow = "stamp_window", + StampIssued = "stamp_issued", + StampAdvisory = "stamp_advisory", + StampReplayed = "stamp_replayed", + StampStore = "stamp_store", + StoreNotShared = "replay_store_not_shared", + KeysUnavailable = "keys_unavailable", + Cancelled = "cancelled", + Malformed = "malformed", + Lifetime = "lifetime", + Issuer = "issuer", + UnknownKey = "unknown_key", + InvalidKey = "invalid_key", + FixtureKey = "fixture_key", + Signature = "signature", + Audience = "audience", + NotYetValid = "not_yet_valid", + Expired = "expired", + Attenuation = "attenuation", + Grant = "grant", +} +const reasonText: Record = { + [Reason.Configuration]: "the verifier configuration is incomplete", + [Reason.StampProof]: "the call carries no hop admission signed for it", + [Reason.StampToken]: "the stamp binds no single Work Context", + [Reason.StampDigest]: "the Work Context does not match the hop stamp", + [Reason.StampIdentity]: "the stamp does not match the authenticated Work Context", + [Reason.StampMethod]: "the hop admission was signed for another procedure", + [Reason.StampAudience]: "the hop admission was signed for another module", + [Reason.StampWindow]: "the hop admission is expired or outside its validity window", + [Reason.StampIssued]: "the hop admission is issued further ahead than the clock allowance", + [Reason.StampAdvisory]: "an advisory policy refusal cannot admit a call", + [Reason.StampReplayed]: "the hop admission has already been used", + [Reason.StoreNotShared]: "the admission store must be durable and shared outside local", + [Reason.StampStore]: "the hop admission store is unavailable", + [Reason.KeysUnavailable]: "the authority's published keys are unavailable", + [Reason.Cancelled]: "the call ended while the authority's keys were being read", + [Reason.Malformed]: "the Work Context encoding or claims are invalid", + [Reason.Lifetime]: "the capability lifetime exceeds the allowed bound", + [Reason.Issuer]: "the Work Context was issued by an untrusted issuer", + [Reason.UnknownKey]: "no verification key is published for this Work Context", + [Reason.InvalidKey]: "the authority's verification key is malformed", + [Reason.FixtureKey]: "the conformance fixture key is refused", + [Reason.Signature]: "the signature does not verify under the published key", + [Reason.Audience]: "the Work Context was minted for another audience", + [Reason.NotYetValid]: "the Work Context is not valid before its not-before instant", + [Reason.Expired]: "the Work Context has expired", + [Reason.Attenuation]: "the actor chain widens authority", + [Reason.Grant]: "the grant structure is invalid", +}; +/** Fixed caller-safe code and text; never retains a diagnostic cause. */ +export class Unauthenticated extends Error { + override name = "Unauthenticated"; + readonly text: string; + constructor(readonly code: Reason) { + super(`${code}: ${reasonText[code]}`); + this.text = reasonText[code]; + } +} diff --git a/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts b/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts index ca93200d..c910b471 100644 --- a/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts +++ b/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts @@ -19,7 +19,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/authority/v1/authority.proto. */ export const file_host_authority_v1_authority: GenFile = /*@__PURE__*/ - fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCJKCgxLZXlzUmVzcG9uc2USDgoGaXNzdWVyGAEgASgJEioKBGtleXMYAiADKAsyHC5ob3N0LmF1dGhvcml0eS52MS5QdWJsaWNLZXkiQgoJUHVibGljS2V5Eg4KBmtleV9pZBgBIAEoCRIRCglhbGdvcml0aG0YAiABKAkSEgoKcHVibGljX2tleRgDIAEoDCJeChRMaXN0UmVzb3VyY2VzUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhdWRpZW5jZRgCIAEoCRIVCg1yZXNvdXJjZV9raW5kGAMgASgJEg4KBmFjdGlvbhgEIAEoCSJJChVMaXN0UmVzb3VyY2VzUmVzcG9uc2USMAoGZ3JhbnRzGAEgAygLMiAuaG9zdC5hdXRob3JpdHkudjEuUmVzb3VyY2VHcmFudCKIAQoVQXR0YWNoUmVzb3VyY2VSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWQXR0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkikgEKFURldGFjaFJlc291cmNlUmVxdWVzdBI0CgVhY3RvchgBIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRITCgtyZXNvdXJjZV9pZBgEIAEoCSIxChZEZXRhY2hSZXNvdXJjZVJlc3BvbnNlEhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCSK7AQoYQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkSFQoNZnJvbV9vd25lcl9pZBgFIAEoCRIXCg90b19wcmluY2lwYWxfaWQYBiABKAkiSAoZQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRIUCgxwcmluY2lwYWxfaWQYASABKAkSFQoNYXV0aG9yaXplZF9ieRgCIAEoCSJ1Cg1SZXNvdXJjZUdyYW50Eg0KA2FsbBgBIAEoCEgAEgwKAmlkGAIgASgJSAASEAoGcHJlZml4GAMgASgJSAASEQoHcGF0dGVybhgEIAEoCUgAEhkKEXJlc291cmNlX293bmVyX2lkGAUgASgJQgcKBW1hdGNoMuoHChBBdXRob3JpdHlTZXJ2aWNlEmoKBUlzc3VlEh8uaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5Omlzc3VlEmoKBUNoZWNrEh8uaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5OmNoZWNrEkcKBEtleXMSHi5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVxdWVzdBofLmhvc3QuYXV0aG9yaXR5LnYxLktleXNSZXNwb25zZRJiCg1MaXN0UmVzb3VyY2VzEicuaG9zdC5hdXRob3JpdHkudjEuTGlzdFJlc291cmNlc1JlcXVlc3QaKC5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVzcG9uc2USZQoOQXR0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5BdHRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5BdHRhY2hSZXNvdXJjZVJlc3BvbnNlEmUKDkRldGFjaFJlc291cmNlEiguaG9zdC5hdXRob3JpdHkudjEuRGV0YWNoUmVzb3VyY2VSZXF1ZXN0GikuaG9zdC5hdXRob3JpdHkudjEuRGV0YWNoUmVzb3VyY2VSZXNwb25zZRJuChFBdXRob3JpemVUcmFuc2ZlchIrLmhvc3QuYXV0aG9yaXR5LnYxLkF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBosLmhvc3QuYXV0aG9yaXR5LnYxLkF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USTQoIRGVsZWdhdGUSIi5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0ZVJlcXVlc3QaHS5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0aW9uEmsKEFJldm9rZURlbGVnYXRpb24SKi5ob3N0LmF1dGhvcml0eS52MS5SZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBorLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXNwb25zZRJXCg1HZXREZWxlZ2F0aW9uEicuaG9zdC5hdXRob3JpdHkudjEuR2V0RGVsZWdhdGlvblJlcXVlc3QaHS5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0aW9uQi9aLWdhdGV3YXkvcGtnL2dlbi9ob3N0L2F1dGhvcml0eS92MTthdXRob3JpdHl2MWIGcHJvdG8z", [file_google_api_annotations, file_host_authority_v1_work_context]); + fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCKAAQoMS2V5c1Jlc3BvbnNlEg4KBmlzc3VlchgBIAEoCRIqCgRrZXlzGAIgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5EjQKDmFkbWlzc2lvbl9rZXlzGAMgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5IlMKCVB1YmxpY0tleRIOCgZrZXlfaWQYASABKAkSEQoJYWxnb3JpdGhtGAIgASgJEhIKCnB1YmxpY19rZXkYAyABKAwSDwoHcHVycG9zZRgEIAEoCSJnChtSZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEgoKcHVibGljX2tleRgCIAEoDCJiChxSZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEikKA2tleRgBIAEoCzIcLmhvc3QuYXV0aG9yaXR5LnYxLlB1YmxpY0tleRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMiXgoUTGlzdFJlc291cmNlc1JlcXVlc3QSDQoFdG9rZW4YASABKAkSEAoIYXVkaWVuY2UYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRIOCgZhY3Rpb24YBCABKAkiSQoVTGlzdFJlc291cmNlc1Jlc3BvbnNlEjAKBmdyYW50cxgBIAMoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLlJlc291cmNlR3JhbnQiiAEKFUF0dGFjaFJlc291cmNlUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJIjEKFkF0dGFjaFJlc291cmNlUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJIpIBChVEZXRhY2hSZXNvdXJjZVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWRGV0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiuwEKGEF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJEhUKDWZyb21fb3duZXJfaWQYBSABKAkSFwoPdG9fcHJpbmNpcGFsX2lkGAYgASgJIkgKGUF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USFAoMcHJpbmNpcGFsX2lkGAEgASgJEhUKDWF1dGhvcml6ZWRfYnkYAiABKAkidQoNUmVzb3VyY2VHcmFudBINCgNhbGwYASABKAhIABIMCgJpZBgCIAEoCUgAEhAKBnByZWZpeBgDIAEoCUgAEhEKB3BhdHRlcm4YBCABKAlIABIZChFyZXNvdXJjZV9vd25lcl9pZBgFIAEoCUIHCgVtYXRjaDLjCAoQQXV0aG9yaXR5U2VydmljZRJqCgVJc3N1ZRIfLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTppc3N1ZRJqCgVDaGVjaxIfLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTpjaGVjaxJHCgRLZXlzEh4uaG9zdC5hdXRob3JpdHkudjEuS2V5c1JlcXVlc3QaHy5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVzcG9uc2USdwoUUmVnaXN0ZXJBZG1pc3Npb25LZXkSLi5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QaLy5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEmIKDUxpc3RSZXNvdXJjZXMSJy5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVxdWVzdBooLmhvc3QuYXV0aG9yaXR5LnYxLkxpc3RSZXNvdXJjZXNSZXNwb25zZRJlCg5BdHRhY2hSZXNvdXJjZRIoLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVxdWVzdBopLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVzcG9uc2USZQoORGV0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlc3BvbnNlEm4KEUF1dGhvcml6ZVRyYW5zZmVyEisuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0GiwuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRJNCghEZWxlZ2F0ZRIiLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRlUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb24SawoQUmV2b2tlRGVsZWdhdGlvbhIqLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXF1ZXN0GisuaG9zdC5hdXRob3JpdHkudjEuUmV2b2tlRGVsZWdhdGlvblJlc3BvbnNlElcKDUdldERlbGVnYXRpb24SJy5ob3N0LmF1dGhvcml0eS52MS5HZXREZWxlZ2F0aW9uUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25CL1otZ2F0ZXdheS9wa2cvZ2VuL2hvc3QvYXV0aG9yaXR5L3YxO2F1dGhvcml0eXYxYgZwcm90bzM", [file_google_api_annotations, file_host_authority_v1_work_context]); /** * IssueRequest names on whose behalf the capability is issued. @@ -535,11 +535,25 @@ export type KeysResponse = Message<"host.authority.v1.KeysResponse"> & { issuer: string; /** - * The public keys by key id. + * The public keys by key id: what a Work Context is signed with, and only + * that. Each carries purpose "work-context". * * @generated from field: repeated host.authority.v1.PublicKey keys = 2; */ keys: PublicKey[]; + + /** + * The keys a hop signs each call's admission with, each with purpose + * "hop-admission": a module behind the hop verifies the admission by one of + * them. More than one is published while a hop's key is rotated, so the + * retiring and the new key overlap. They are never Work Context keys and are + * kept out of keys, so no verifier that reads keys alone can trust one to + * sign a Work Context. Empty until a hop registers its key + * (RegisterAdmissionKey). + * + * @generated from field: repeated host.authority.v1.PublicKey admission_keys = 3; + */ + admissionKeys: PublicKey[]; }; /** @@ -575,6 +589,15 @@ export type PublicKey = Message<"host.authority.v1.PublicKey"> & { * @generated from field: bytes public_key = 3; */ publicKey: Uint8Array; + + /** + * What the key verifies: "work-context" (a Work Context) or "hop-admission" + * (a hop's signed call admission). A verifier uses a key only for its own + * purpose. + * + * @generated from field: string purpose = 4; + */ + purpose: string; }; /** @@ -584,6 +607,62 @@ export type PublicKey = Message<"host.authority.v1.PublicKey"> & { export const PublicKeySchema: GenMessage = /*@__PURE__*/ messageDesc(file_host_authority_v1_authority, 15); +/** + * RegisterAdmissionKeyRequest presents one hop admission key. + * + * @generated from message host.authority.v1.RegisterAdmissionKeyRequest + */ +export type RegisterAdmissionKeyRequest = Message<"host.authority.v1.RegisterAdmissionKeyRequest"> & { + /** + * The hop's workload credential; any other workload is refused. + * + * @generated from field: host.authority.v1.WorkloadCredential actor = 1; + */ + actor?: WorkloadCredential; + + /** + * The raw Ed25519 public key the hop signs admissions with: 32 bytes. + * + * @generated from field: bytes public_key = 2; + */ + publicKey: Uint8Array; +}; + +/** + * Describes the message host.authority.v1.RegisterAdmissionKeyRequest. + * Use `create(RegisterAdmissionKeyRequestSchema)` to create a new message. + */ +export const RegisterAdmissionKeyRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_authority_v1_authority, 16); + +/** + * RegisterAdmissionKeyResponse is the key as Keys now publishes it. + * + * @generated from message host.authority.v1.RegisterAdmissionKeyResponse + */ +export type RegisterAdmissionKeyResponse = Message<"host.authority.v1.RegisterAdmissionKeyResponse"> & { + /** + * The published entry, with its key id and purpose "hop-admission". + * + * @generated from field: host.authority.v1.PublicKey key = 1; + */ + key?: PublicKey; + + /** + * When the registration lapses unless the hop registers the key again. + * + * @generated from field: int64 expires_at_unix = 2; + */ + expiresAtUnix: bigint; +}; + +/** + * Describes the message host.authority.v1.RegisterAdmissionKeyResponse. + * Use `create(RegisterAdmissionKeyResponseSchema)` to create a new message. + */ +export const RegisterAdmissionKeyResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_authority_v1_authority, 17); + /** * ListResourcesRequest asks about one kind and one action. * @@ -624,7 +703,7 @@ export type ListResourcesRequest = Message<"host.authority.v1.ListResourcesReque * Use `create(ListResourcesRequestSchema)` to create a new message. */ export const ListResourcesRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 16); + messageDesc(file_host_authority_v1_authority, 18); /** * ListResourcesResponse is every grant the capability holds on the kind for the @@ -645,7 +724,7 @@ export type ListResourcesResponse = Message<"host.authority.v1.ListResourcesResp * Use `create(ListResourcesResponseSchema)` to create a new message. */ export const ListResourcesResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 17); + messageDesc(file_host_authority_v1_authority, 19); /** * AttachResourceRequest names one resource and the capability it was created under. @@ -687,7 +766,7 @@ export type AttachResourceRequest = Message<"host.authority.v1.AttachResourceReq * Use `create(AttachResourceRequestSchema)` to create a new message. */ export const AttachResourceRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 18); + messageDesc(file_host_authority_v1_authority, 20); /** * AttachResourceResponse names the installation that now owns the resource. @@ -708,7 +787,7 @@ export type AttachResourceResponse = Message<"host.authority.v1.AttachResourceRe * Use `create(AttachResourceResponseSchema)` to create a new message. */ export const AttachResourceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 19); + messageDesc(file_host_authority_v1_authority, 21); /** * DetachResourceRequest names one resource its serving module deleted. @@ -750,7 +829,7 @@ export type DetachResourceRequest = Message<"host.authority.v1.DetachResourceReq * Use `create(DetachResourceRequestSchema)` to create a new message. */ export const DetachResourceRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 20); + messageDesc(file_host_authority_v1_authority, 22); /** * DetachResourceResponse names the installation that owned the resource. @@ -771,7 +850,7 @@ export type DetachResourceResponse = Message<"host.authority.v1.DetachResourceRe * Use `create(DetachResourceResponseSchema)` to create a new message. */ export const DetachResourceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 21); + messageDesc(file_host_authority_v1_authority, 23); /** * AuthorizeTransferRequest names one existing resource and where it moves. @@ -828,7 +907,7 @@ export type AuthorizeTransferRequest = Message<"host.authority.v1.AuthorizeTrans * Use `create(AuthorizeTransferRequestSchema)` to create a new message. */ export const AuthorizeTransferRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 22); + messageDesc(file_host_authority_v1_authority, 24); /** * AuthorizeTransferResponse is the decision the store records. @@ -856,7 +935,7 @@ export type AuthorizeTransferResponse = Message<"host.authority.v1.AuthorizeTran * Use `create(AuthorizeTransferResponseSchema)` to create a new message. */ export const AuthorizeTransferResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 23); + messageDesc(file_host_authority_v1_authority, 25); /** * ResourceGrant is one way of naming covered resources. Exactly one match is @@ -917,7 +996,7 @@ export type ResourceGrant = Message<"host.authority.v1.ResourceGrant"> & { * Use `create(ResourceGrantSchema)` to create a new message. */ export const ResourceGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 24); + messageDesc(file_host_authority_v1_authority, 26); /** * AuthorityService is implemented by the host (accounts) and by the local authority. @@ -951,7 +1030,9 @@ export const AuthorityService: GenService<{ output: typeof CheckResponseSchema; }, /** - * Keys returns the issuer and the public keys a verifier pins. + * Keys returns the issuer and the public keys a verifier pins: the Work + * Context keys, and the hop admission keys a module behind the hop checks + * each call's admission by. * * @generated from rpc host.authority.v1.AuthorityService.Keys */ @@ -960,6 +1041,25 @@ export const AuthorityService: GenService<{ input: typeof KeysRequestSchema; output: typeof KeysResponseSchema; }, + /** + * RegisterAdmissionKey is how the hop tells the authority the public key it + * signs call admissions with, so Keys publishes it in admission_keys. The + * caller is the hop, proving it with its workload credential; any other + * workload is refused by name. Only the public half travels: the hop's seed + * never leaves it. Registering the same key again is idempotent and renews + * it, and a registration lapses at expires_at_unix unless renewed, so a hop + * renews while it runs (a restarted authority learns the key again at the + * next renewal) and a key no running hop signs with any more is withdrawn + * without being named. While a rotation overlaps, the retiring and the new + * key are both registered, and both published. + * + * @generated from rpc host.authority.v1.AuthorityService.RegisterAdmissionKey + */ + registerAdmissionKey: { + methodKind: "unary"; + input: typeof RegisterAdmissionKeyRequestSchema; + output: typeof RegisterAdmissionKeyResponseSchema; + }, /** * ListResources answers which resources of one kind a checked capability may * act on with one action, decided by the authority at the time of the call diff --git a/libraries/authority-client/typescript/tests/owner.test.mjs b/libraries/authority-client/typescript/tests/owner.test.mjs index 40dfe84c..caad8573 100644 --- a/libraries/authority-client/typescript/tests/owner.test.mjs +++ b/libraries/authority-client/typescript/tests/owner.test.mjs @@ -8,7 +8,7 @@ import { createHash, createPrivateKey, sign } from 'node:crypto'; import { test } from 'node:test'; import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; import { KeysResponseSchema, PublicKeySchema, WorkContextV1Schema, WorkScopeV1Schema } from '@codefly-dev/authority-client'; -import { authenticateClaims, identityOf, trustFromKeys, Unauthenticated } from '@codefly-dev/authority-client/work-context'; +import { authenticateToken, boundToken, identityOf, trustFromKeys, Unauthenticated } from '@codefly-dev/authority-client/work-context'; const fixtures = JSON.parse(readFileSync(new URL('../../testdata/claims.json',import.meta.url),'utf8')); const encode = bytes => Buffer.from(bytes).toString('base64url'); @@ -18,7 +18,7 @@ const body = name => fromBinary(WorkContextV1Schema, Buffer.from(fixtures.find(f const signed = context => { const payload = toBinary(WorkContextV1Schema,context); return encode(payload)+'.'+encode(sign(null,payload,signer)); }; const trustFor = context => trustFromKeys(create(KeysResponseSchema,{issuer:context.issuer,keys:[create(PublicKeySchema,{keyId:context.keyId,algorithm:'Ed25519',publicKey})]}), ()=>(Number(context.notBeforeUnix)+1)*1000); const stamped = token => ({'x-codefly-work-context':token,'x-codefly-hop-proof':'test-proof','x-codefly-hop-context':createHash('sha256').update(token).digest('hex')}); -const read = context => authenticateClaims(stamped(signed(context)),'test-proof',context.audience,trustFor(context)); +const read = context => authenticateToken(signed(context), context.audience, trustFor(context)); const scope = fields => create(WorkScopeV1Schema, fields); // The owner-selected rows (allows refusing, allowsOwned admitting only the diff --git a/libraries/authority-client/typescript/tests/work-context.test.mjs b/libraries/authority-client/typescript/tests/work-context.test.mjs index 3688ce61..8e22def9 100644 --- a/libraries/authority-client/typescript/tests/work-context.test.mjs +++ b/libraries/authority-client/typescript/tests/work-context.test.mjs @@ -1,10 +1,15 @@ +import { createPrivateKey as hopPrivateKey, randomBytes as hopRandom, sign as hopSign } from "node:crypto"; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { createHash, createPrivateKey, generateKeyPairSync, sign } from 'node:crypto'; import { test } from 'node:test'; import { create, fromBinary, fromJson, toBinary } from '@bufbuild/protobuf'; import { KeysResponseSchema, PublicKeySchema, WorkContextV1Schema, WorkScopeV1Schema } from '@codefly-dev/authority-client'; -import { authenticateClaims, identityOf, trustFromKeys, Unauthenticated, Verifier, Reason } from '@codefly-dev/authority-client/work-context'; +import { authenticateToken, boundToken, identityOf, trustFromKeys, Unauthenticated, Verifier, Replayed, Expired, Reason, SKEW, NoAdmissionKeys } from '@codefly-dev/authority-client/work-context'; + +// Every verifier here runs in the local run, which alone may use a +// process-local replay store; the startup guard's test sets its own. +process.env.CODEFLY__ENVIRONMENT = 'local'; const fixtures = JSON.parse(readFileSync(new URL('../../testdata/claims.json',import.meta.url),'utf8')); const encode = bytes => Buffer.from(bytes).toString('base64url'); @@ -15,7 +20,7 @@ const body = name => fromBinary(WorkContextV1Schema, Buffer.from(fixtures.find(f const signed = (context, key=signer) => { const payload = toBinary(WorkContextV1Schema,context); return encode(payload)+'.'+encode(sign(null,payload,key)); }; const trustFor = (context, at=(Number(context.notBeforeUnix)+1)*1000) => trustFromKeys(create(KeysResponseSchema,{issuer:context.issuer,keys:[create(PublicKeySchema,{keyId:context.keyId,algorithm:'Ed25519',publicKey})]}), ()=>at); const stamped = (token, proof='test-proof') => ({'x-codefly-work-context':token,'x-codefly-hop-proof':proof,'x-codefly-hop-context':createHash('sha256').update(token).digest('hex')}); -const read = context => authenticateClaims(stamped(signed(context)),'test-proof',context.audience,trustFor(context)); +const read = context => authenticateToken(signed(context), context.audience, trustFor(context)); const scope = (actions, resourceIds=[]) => create(WorkScopeV1Schema,{resourceKind:'profiles',actions,resourceIds}); for (const fixture of fixtures) { @@ -55,21 +60,21 @@ test('a forged, unsigned, misaddressed or expired context is refused by name', ( [signed(create(WorkContextV1Schema,{...context, keyId:'rotated'})), context.audience, trust, /no verification key/], [signed(context), '', trust, /configuration/], ]) { - assert.throws(()=>authenticateClaims(stamped(token),'test-proof',audience,use), e => e instanceof Unauthenticated && reason.test(e.message)); + assert.throws(()=>authenticateToken(token, audience, use), e => e instanceof Unauthenticated && reason.test(e.message)); } }); test('the stamp must bind the token under this run\'s proof', () => { const context = body('session'), token = signed(context), trust = trustFor(context); for (const headers of [ - {...stamped(token), 'x-codefly-hop-proof':'forged'}, {...stamped(token), 'x-codefly-hop-context':'0'.repeat(64)}, {...stamped(token), 'x-codefly-work-context':signed(body('operation'))}, [...Object.entries(stamped(token)), ['x-codefly-work-context',token]], - ]) assert.throws(()=>authenticateClaims(headers,'test-proof',context.audience,trust),Unauthenticated); - assert.throws(()=>authenticateClaims(stamped(token),'',context.audience,trust),Unauthenticated); + // boundToken refuses the binding; authenticateToken then the token. Two + // calls, because neither of them admits a call (review r164 R4). + ]) assert.throws(()=>authenticateToken(boundToken(headers), context.audience, trust),Unauthenticated); for (const bad of ['abc','a.b.c','é.x', payloadOnly(token)+'.AA']) { - assert.throws(()=>authenticateClaims(stamped(bad),'test-proof',context.audience,trust),Unauthenticated); + assert.throws(()=>authenticateToken(bad, context.audience, trust),Unauthenticated); } }); @@ -88,28 +93,41 @@ const vectors = JSON.parse(readFileSync(new URL('../../testdata/vectors.json',im for (const v of vectors) { test(`shared vector, Go's verdict: ${v.name}`, () => { const trust = {issuer: v.issuer, keys: new Map(Object.entries(v.keys).map(([kid, raw]) => [kid, new Uint8Array(Buffer.from(raw,'base64'))])), now: () => v.now*1000}; - const run = () => authenticateClaims(stamped(v.token),'test-proof',v.audience,trust); + const run = () => authenticateToken(v.token, v.audience, trust); if (v.accept) run(); else assert.throws(run, e => e instanceof Unauthenticated && e.message.includes(v.reason), v.reason); }); } const fixture = JSON.parse(readFileSync(new URL('../../../hop/testdata/stamped.json',import.meta.url),'utf8')); +const METHOD = "/test.Service/Read"; +const replay = () => { const seen = new Set(); return { async consume(nonce) { if (seen.has(nonce)) throw new Replayed(); seen.add(nonce); } }; }; +const hopKey = label => hopPrivateKey({key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), createHash("sha256").update(label).digest()]), format: "der", type: "pkcs8"}); +const fresh = (now, method = METHOD, audience = fixture.audience, headers = fixture.headers, hop = "fixture-proof") => { + const payload = Buffer.from(JSON.stringify(["codefly-hop-v1", method, audience, createHash("sha256").update(headers["x-codefly-work-context"]).digest("hex"), hopRandom(32).toString("hex"), Math.floor(now/1000), Math.floor(now/1000)+30])); + return {...headers, "x-codefly-hop-proof": payload.toString("base64url")+"."+hopSign(null,payload,hopKey(hop)).toString("base64url")}; +}; +// The admission key of the test hop that signs as label, as Keys publishes it. +const hopPublished = label => create(PublicKeySchema, {keyId: `hop-${label}`, algorithm: 'Ed25519', purpose: 'hop-admission', + publicKey: Buffer.from(hopKey(label).export({format: 'jwk'}).x, 'base64url')}); +// The fixture's Work Context keys, and the admission keys of these hops. +const publishing = (...labels) => create(KeysResponseSchema, {issuer: fromJson(KeysResponseSchema, fixture.keys).issuer, keys: fromJson(KeysResponseSchema, fixture.keys).keys, admissionKeys: labels.map(hopPublished)}); + const published = fromJson(KeysResponseSchema, fixture.keys); const source = (answer = () => Promise.resolve(published)) => { const s = {reads: 0, keys: () => { s.reads++; return answer(); }}; return s; }; const settle = () => new Promise(resolve => setImmediate(resolve)); test('the Verifier reads the hop fixture as Go reads it', async () => { const keys = source(); - const v = new Verifier(keys, fixture.audience, fixture.proof, () => fixture.now*1000); - const id = await v.verify(fixture.headers); + const v = new Verifier(keys, fixture.audience, replay(), () => fixture.now*1000); + const id = await v.verify(fresh(v.now()), METHOD); assert.equal(id.owner, fixture.identity.owner); assert.equal(id.tenant, fixture.identity.tenant); assert.deepEqual([...id.scopes], fixture.identity.scopes); assert.equal(id.advisory, fixture.identity.advisory); assert.ok(id.claims.allows('documents.collection','write','c-1')); - for (const [key, value, reason] of [['x-codefly-hop-tenant','globex',/stamp_identity/],['x-codefly-hop-scopes','documents.collection:admin',/stamp_identity/],['x-codefly-hop-proof','another-run',/proof/]]) { - await assert.rejects(v.verify({...fixture.headers, [key]: value}), e => e instanceof Unauthenticated && reason.test(e.message)); + for (const [key, value, reason] of [['x-codefly-hop-tenant','globex',/stamp_identity/],['x-codefly-hop-scopes','documents.collection:admin',/stamp_identity/],['x-codefly-hop-proof','another-run',/stamp_proof/]]) { + await assert.rejects(v.verify({...fresh(v.now()), [key]: value}, METHOD), e => e instanceof Unauthenticated && reason.test(e.message)); } }); @@ -120,17 +138,17 @@ test('a call without the proof never reads the keys, and a hung read stalls no h let hang = false; const keys = source(() => hang ? new Promise(resolve => { release = () => resolve(published); }) : Promise.resolve(published)); let clock = fixture.now*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); assert.equal(keys.reads, 1); for (let i = 0; i < 20; i++) { clock += 11_000; - await assert.rejects(v.verify({'x-codefly-work-context': 'garbage'}), Unauthenticated); + await assert.rejects(v.verify({'x-codefly-work-context': 'garbage'}, METHOD), Unauthenticated); } assert.equal(keys.reads, 1, 'a proof-less call read the keys'); hang = true; clock = fixture.now*1000 + 400_000; // keys older than max age: a background re-read starts and hangs - const id = await v.verify(fixture.headers); + const id = await v.verify(fresh(v.now()), METHOD); assert.equal(id.owner, fixture.identity.owner); assert.equal(keys.reads, 2); release(); @@ -140,12 +158,12 @@ test('a Verifier starts before the authority serves keys and refuses with a reas let up = false; const keys = source(() => up ? Promise.resolve(published) : Promise.reject(new Error('dial authority:4000: connection refused'))); let clock = fixture.now*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); - await assert.rejects(v.verify(fixture.headers), e => e instanceof Unauthenticated && /unavailable/.test(e.message) && !/4000/.test(e.message)); + await assert.rejects(v.verify(fresh(v.now()), METHOD), e => e instanceof Unauthenticated && /unavailable/.test(e.message) && !/4000/.test(e.message)); up = true; clock += 11_000; - assert.equal((await v.verify(fixture.headers)).owner, fixture.identity.owner); + assert.equal((await v.verify(fresh(v.now()), METHOD)).owner, fixture.identity.owner); }); // The robin playground's model gateway, live: its first call after 39 idle @@ -154,11 +172,12 @@ test('a Verifier starts before the authority serves keys and refuses with a reas // judge a call, so the call waits for the read, as it does for a key not held. // The keys are read before the fixture's token is minted, so the token is live. for (const mode of ['stamped', 'bare']) { - const check = v => mode === 'bare' ? v.verifyToken(fixture.headers['x-codefly-work-context']) : v.verify(fixture.headers); + // Each admission is single-use, so the stamped mode signs a fresh one per call. + const check = v => mode === 'bare' ? v.verifyToken(fixture.headers['x-codefly-work-context']) : v.verify(fresh(v.now()), METHOD); test(mode + ': a call on keys past the stale window waits for the re-read', async () => { const keys = source(); let clock = (fixture.now - 960)*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); clock = fixture.now*1000; assert.equal((await check(v)).owner, fixture.identity.owner); @@ -171,7 +190,7 @@ test(mode + ': a call on keys past the stale window is refused when the re-read let fail; const keys = source(() => keys.reads === 1 ? Promise.resolve(published) : new Promise((_, reject) => { fail = reject; })); let clock = (fixture.now - 960)*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); clock = fixture.now*1000; let judged = false; @@ -190,7 +209,7 @@ test(mode + ': a call on keys past the stale window is refused when the re-read let hang = false; const keys = source(() => hang ? new Promise(() => {}) : Promise.resolve(published)); let clock = (fixture.now - 960)*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock, 100); + const v = new Verifier(keys, fixture.audience, replay(), () => clock, 100); await settle(); hang = true; clock = fixture.now*1000; @@ -216,7 +235,7 @@ test('readiness follows trust, not the last read', async () => { let failing = false; const keys = source(() => failing ? Promise.reject(new Error('authority down')) : Promise.resolve(published)); let clock = fixture.now*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); assert.equal(v.ready(), true); failing = true; @@ -234,7 +253,7 @@ test('an unready replica becomes ready by polling alone', async () => { let serving = false; const keys = source(() => serving ? Promise.resolve(published) : Promise.reject(new Error('not serving yet'))); let clock = fixture.now*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); let ready = false; for (let poll = 0; poll < 30 && !ready; poll++) { @@ -252,7 +271,7 @@ test('an unready replica becomes ready by polling alone', async () => { test('an idle replica polled for readiness stays ready', async () => { const keys = source(); let clock = fixture.now*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); for (let minute = 1; minute <= 60; minute++) { clock += 60_000; @@ -263,10 +282,44 @@ test('an idle replica polled for readiness stays ready', async () => { assert.ok(keys.reads >= 10 && keys.reads <= 14, `reads=${keys.reads}`); }); +test('review probes: read stamp cannot retarget, cross audiences or replay across replicas', async () => { + const store = replay(); + const first = new Verifier(source(), fixture.audience, store, () => fixture.now*1000); + const second = new Verifier(source(), fixture.audience, store, () => fixture.now*1000); + for (const method of ['/documents.v1.Service/Delete','/datasets.v1.Service/Register','Read','','POST '+METHOD]) { + await assert.rejects(first.verify(fixture.headers, method), /stamp_method/); + } + const other = new Verifier(source(), 'records', store, () => fixture.now*1000); + await assert.rejects(other.verify(fixture.headers, METHOD), /stamp_audience/); + assert.equal((await first.verify(fixture.headers, METHOD)).claims.context.replayPolicy, "single-use"); + await assert.rejects(first.verify(fixture.headers, METHOD), /stamp_replayed/); + await assert.rejects(second.verify(fixture.headers, METHOD), /stamp_replayed/); + await assert.rejects(second.verify({...fixture.headers, 'x-codefly-hop-proof': 'fixture-proof'}, METHOD), /proof/); +}); + +test('concurrent replay has one winner and a failed store refuses admission', async () => { + const store = replay(); + const replicas = [0,1].map(() => new Verifier(source(), fixture.audience, store, () => fixture.now*1000)); + const results = await Promise.allSettled(Array.from({length:32}, (_,n) => replicas[n%2].verify(fixture.headers, METHOD))); + assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); + const v = new Verifier(source(), fixture.audience, {async consume(){throw Error('store down');}}, () => fixture.now*1000); + await assert.rejects(v.verify(fixture.headers, METHOD), /stamp_store/); +}); + +test('expired stamps and repeated binding headers refuse before consumption', async () => { + const expired = new Verifier(source(), fixture.audience, replay(), () => (fixture.now+30+SKEW)*1000); + await assert.rejects(expired.verify(fixture.headers, METHOD), /stamp_window/); + for (const key of ['x-codefly-hop-proof', 'x-codefly-work-context','x-codefly-hop-context']) { + const v = new Verifier(source(), fixture.audience, replay(), () => fixture.now*1000); + await assert.rejects(v.verify([...Object.entries(fixture.headers),[key,fixture.headers[key]]], METHOD), Unauthenticated); + await v.verify(fixture.headers, METHOD); + } +}); + for (const row of vectors) { test(`bare token matches shared verdict: ${row.name}`, async () => { const response = create(KeysResponseSchema, {issuer: row.issuer, keys: Object.entries(row.keys).map(([keyId, raw]) => create(PublicKeySchema,{keyId,algorithm:'Ed25519',publicKey:Buffer.from(raw,'base64')}))}); - const v = new Verifier(source(()=>Promise.resolve(response)), row.audience, 'proof', ()=>row.now*1000); + const v = new Verifier(source(()=>Promise.resolve(response)), row.audience, undefined, ()=>row.now*1000); if (row.accept) assert.equal((await v.verifyToken(row.token)).claims.audience,row.audience); else await assert.rejects(v.verifyToken(row.token), e=> e instanceof Unauthenticated && e.code === row.reason && !e.message.includes(row.token)); }); @@ -279,19 +332,20 @@ test('every named reason has distinct code and safe text', () => { }); test('stamp failures retain distinct safe codes', async () => { - const v=new Verifier(source(),fixture.audience,fixture.proof,()=>fixture.now*1000); + const v=new Verifier(source(),fixture.audience,replay(),()=>fixture.now*1000); for (const [change,code] of [ [{'x-codefly-hop-proof':'private-proof'},Reason.StampProof], [{'x-codefly-work-context':''},Reason.StampToken], [{'x-codefly-hop-context':'private-digest'},Reason.StampDigest], [{'x-codefly-hop-tenant':'private-tenant'},Reason.StampIdentity], - ]) await assert.rejects(v.verify({...fixture.headers,...change}), e=>e.code===code && !e.message.includes('private-')); + ]) await assert.rejects(v.verify({...fixture.headers,...change}, METHOD), e=>e.code===code && !e.message.includes('private-')); }); -test('bare-only process needs no proof and cannot accept stamps', async () => { +test('bare-only process needs no admission keys and cannot accept stamps', async () => { const v = new Verifier(source(),fixture.audience,undefined,()=>fixture.now*1000); assert.equal((await v.verifyToken(fixture.headers['x-codefly-work-context'])).owner,fixture.identity.owner); - await assert.rejects(v.verify(fixture.headers),Unauthenticated); + // Without the hop public key and a shared replay store it admits nothing. + await assert.rejects(v.verify(fixture.headers, METHOD), e=>e.code===Reason.Configuration); }); test('invalid publication and unreadable keys have distinct safe codes', async () => { @@ -299,7 +353,217 @@ test('invalid publication and unreadable keys have distinct safe codes', async ( [()=>Promise.resolve(create(KeysResponseSchema,{issuer:'issuer'})), Reason.InvalidKey], [()=>Promise.reject(new Error('private-source-detail')), Reason.KeysUnavailable], ]) { - const v=new Verifier(source(answer),fixture.audience,fixture.proof,()=>fixture.now*1000); + const v=new Verifier(source(answer),fixture.audience,undefined,()=>fixture.now*1000); await assert.rejects(v.verifyToken(fixture.headers['x-codefly-work-context']), e=>e.code===code && !e.message.includes('private-')); } }); + +// Review r164 R1: a verifier whose clock lags the hop refused genuine calls +// whenever the signing instant fell just after a second boundary, under a +// reason that read as an expiry. Go, Python and TypeScript allow the same SKEW. +for (const lag of [0, 1, SKEW]) { + test(`a clock difference of ${lag}s inside the allowance still admits`, async () => { + const v = new Verifier(source(), fixture.audience, replay(), () => (fixture.now-lag)*1000); + assert.equal((await v.verify(fresh(fixture.now*1000), METHOD)).owner, fixture.identity.owner); + }); +} + +for (const [lag, code] of [[SKEW+1, Reason.StampIssued], [-(30+SKEW), Reason.StampWindow]]) { + test(`a clock difference of ${lag}s beyond the allowance is refused as ${code}`, async () => { + const v = new Verifier(source(), fixture.audience, replay(), () => (fixture.now-lag)*1000); + await assert.rejects(v.verify(fresh(fixture.now*1000), METHOD), e => e instanceof Unauthenticated && e.code === code); + }); +} + +// Review r164 R2: the reservation outlives the admission by the same +// allowance, so a store evicting on its own clock cannot release a nonce a +// lagging verifier would still admit. +test('a reservation is retained past expiry by the allowance', async () => { + const retained = []; + const recording = { async consume(_nonce, retainUntil) { retained.push(retainUntil); } }; + const v = new Verifier(source(), fixture.audience, recording, () => fixture.now*1000); + const headers = fresh(fixture.now*1000); + const payload = JSON.parse(Buffer.from(headers['x-codefly-hop-proof'].split('.')[0], 'base64url').toString('utf8')); + await v.verify(headers, METHOD); + assert.deepEqual(retained, [payload[6] + SKEW]); +}); + +// A reservation store as replaypg is: a nonce is held until it is finished AND +// its retention has passed, and a reservation past its retention is refused +// rather than inserted. collect removes what has aged out. +const reservations = clock => { + const store = { shared: true, held: new Map(), active: new Set(), + async reserve(nonce, until) { + if (until * 1000 <= clock()) throw new Expired(); + if (store.held.has(nonce)) throw new Replayed(); + store.held.set(nonce, until); store.active.add(nonce); + }, + async finish(nonce) { store.active.delete(nonce); }, + async consume() { throw new Error('a reservation store is reserved, never consumed'); }, + collect() { for (const [nonce, until] of store.held) if (!store.active.has(nonce) && until * 1000 < clock()) store.held.delete(nonce); }, + }; + return store; +}; + +// Review of #28 at 8a3e953b: a single-use admission is consumed exactly once. +// Two deliveries of one admission arrive inside its window; the first holds its +// reservation while its handler runs, past its retention and collection; the +// second is refused by name, and so is a delivery after the handler returned. +test('a second delivery of one admission is refused while and after the first runs', async () => { + let now = fixture.now * 1000; + const store = reservations(() => now); + const v = new Verifier(source(), fixture.audience, store, () => now); + const headers = fresh(now); + let ran = 0; + await v.admit(headers, METHOD, async identity => { + ran++; + assert.equal(identity.owner, fixture.identity.owner); + await assert.rejects(v.verify(headers, METHOD), e => e.code === Reason.StampReplayed); + now += (30 + SKEW + 5) * 1000; // past the retention while the handler still runs + store.collect(); + assert.equal(store.held.size, 1, 'collection removed the reservation of a running call'); + await assert.rejects(v.admit(headers, METHOD, async () => { ran++; }), e => e.code === Reason.StampWindow); + }); + store.collect(); + assert.equal(store.held.size, 0); + assert.equal(ran, 1); +}); + +// The reservation is taken before the Work Context is authenticated, and a +// refusal there ends the hold rather than leaving it pinned; a second delivery +// still meets it. An admission judged on no keys reserves nothing. +test('the reservation is taken before the work context and released on refusal', async () => { + const now = fixture.now * 1000; + const store = reservations(() => now); + const v = new Verifier(source(), fixture.audience, store, () => now); + const token = fixture.headers['x-codefly-work-context']; + const forged = token.split('.')[0] + '.' + Buffer.alloc(64).toString('base64url'); + const headers = fresh(now, METHOD, fixture.audience, {...fixture.headers, 'x-codefly-work-context': forged, 'x-codefly-hop-context': createHash('sha256').update(forged).digest('hex')}); + await assert.rejects(v.verify(headers, METHOD), e => e.code === Reason.Signature); + assert.equal(store.held.size, 1, 'the call never reserved'); + assert.equal(store.active.size, 0, 'a refused call kept its hold'); + await assert.rejects(v.verify(headers, METHOD), e => e.code === Reason.StampReplayed); + const unread = reservations(() => now); + const down = new Verifier(source(() => Promise.reject(new Error('authority down'))), fixture.audience, unread, () => now, 50); + await assert.rejects(down.verify(fresh(now), METHOD), e => e.code === Reason.KeysUnavailable); + assert.equal(unread.held.size, 0, 'an admission judged on no keys was reserved'); +}); + +// Review r3 B1: the hop's admission key comes from the authority's Keys, the +// read the verifier already makes; nothing about the hop is configured. +test('a verifier takes the hop key from the authority keys alone', async () => { + const v = new Verifier(source(() => Promise.resolve(publishing('the-hop'))), fixture.audience, replay(), () => fixture.now * 1000); + const id = await v.verify(fresh(fixture.now * 1000, METHOD, fixture.audience, fixture.headers, 'the-hop'), METHOD); + assert.equal(id.owner, fixture.identity.owner); +}); + +// A stamp signed by a key the authority does not publish is refused by name, +// and causes no read of the authority's keys. +test('a stamp signed by an unpublished key is refused by name', async () => { + const keys = source(() => Promise.resolve(publishing('the-hop'))); + const v = new Verifier(keys, fixture.audience, replay(), () => fixture.now * 1000); + await settle(); + const reads = keys.reads; + await assert.rejects(v.verify(fresh(fixture.now * 1000, METHOD, fixture.audience, fixture.headers, 'an-unpublished-hop'), METHOD), e => e.code === Reason.StampProof); + await settle(); + assert.equal(keys.reads, reads); +}); + +// While a hop's key rotates both are published and admitted; once the retiring +// key is withdrawn, the next read stops admitting it. +test('a rotation overlap admits both keys until the retiring one is withdrawn', async () => { + let now = fixture.now * 1000, answer = publishing('retiring', 'current'); + const v = new Verifier(source(() => Promise.resolve(answer)), fixture.audience, replay(), () => now); + for (const hop of ['retiring', 'current']) await v.verify(fresh(now, METHOD, fixture.audience, fixture.headers, hop), METHOD); + answer = publishing('current'); + now += 301_000; // past KEY_MAX_AGE: readiness re-reads + v.ready(); + await settle(); + await assert.rejects(v.verify(fresh(now, METHOD, fixture.audience, fixture.headers, 'retiring'), METHOD), e => e.code === Reason.StampProof); + await v.verify(fresh(now, METHOD, fixture.audience, fixture.headers, 'current'), METHOD); +}); + +// Each key serves its own purpose only: an admission key published among the +// Work Context keys admits nothing, and is never trusted for a Work Context. +test('a key is used for its own purpose only', async () => { + const response = publishing(); + response.keys.push(hopPublished('misplaced')); + const v = new Verifier(source(() => Promise.resolve(response)), fixture.audience, replay(), () => fixture.now * 1000); + await assert.rejects(v.verify(fresh(fixture.now * 1000, METHOD, fixture.audience, fixture.headers, 'misplaced'), METHOD), e => e.code === Reason.KeysUnavailable); + assert.ok(!trustFromKeys(response).keys.has('hop-misplaced')); +}); + +// An authority that publishes no admission key admits nothing, under a fixed +// reason, and says why in the module's logs. +test('no published admission key refuses every call and says why', async () => { + const v = new Verifier(source(() => Promise.resolve(publishing())), fixture.audience, replay(), () => fixture.now * 1000); + await assert.rejects(v.verify(fresh(fixture.now * 1000), METHOD), e => e.code === Reason.KeysUnavailable); + assert.ok(v.keysError instanceof NoAdmissionKeys); +}); + +// Review r5: a verifier that first read the keys before the hop registered its +// key holds no admission key; once the authority publishes one, the next call +// or readiness check after KEY_RETRY reads it, without waiting out +// KEY_MAX_AGE, and callers inside KEY_RETRY drive no read. +test('a verifier that read before the hop registered learns its key within KEY_RETRY', async () => { + let now = fixture.now * 1000, answer = publishing(); + const keys = source(() => Promise.resolve(answer)); + const v = new Verifier(keys, fixture.audience, replay(), () => now); + await assert.rejects(v.verify(fresh(now, METHOD, fixture.audience, fixture.headers, 'the-hop'), METHOD), e => e.code === Reason.KeysUnavailable); + await settle(); + const reads = keys.reads; + for (let n = 0; n < 5; n++) await assert.rejects(v.verify(fresh(now, METHOD, fixture.audience, fixture.headers, 'the-hop'), METHOD), e => e.code === Reason.KeysUnavailable); + await settle(); + assert.equal(keys.reads, reads, 'callers inside KEY_RETRY drove a read'); + answer = publishing('the-hop'); + now += 10_000; + const id = await v.verify(fresh(now, METHOD, fixture.audience, fixture.headers, 'the-hop'), METHOD); + assert.equal(id.owner, fixture.identity.owner); + let idleAnswer = publishing(); + const idle = new Verifier(source(() => Promise.resolve(idleAnswer)), fixture.audience, replay(), () => now); + idle.ready(); + await settle(); + idleAnswer = publishing('the-hop'); + now += 10_000; + idle.ready(); + await settle(); + assert.equal(idle.keysError, undefined); +}); + +test('admit releases its hold however the handler ends', async () => { + const now = fixture.now * 1000; + const store = reservations(() => now); + const v = new Verifier(source(), fixture.audience, store, () => now); + await assert.rejects(v.admit(fresh(now), METHOD, async () => { assert.equal(store.active.size, 1); throw new Error('handler failed'); }), /handler failed/); + assert.equal(store.held.size, 1); + assert.equal(store.active.size, 0); +}); + +// A store without reserve keeps a nonce to its retention and may then evict it. +// A delivery that arrived inside the window, but whose consumption completed +// after the retention, found an evicted nonce: it is refused on the window. +test('a consumption completing past its retention is refused', async t => { + let slow = 0, calls = 0; + const real = performance.now.bind(performance); + t.mock.method(performance, 'now', () => real() + slow); + const evicted = { async consume() { if (++calls === 2) slow = 60_000; } }; + const v = new Verifier(source(), fixture.audience, evicted, () => fixture.now * 1000); + await v.verify(fresh(fixture.now * 1000), METHOD); + await assert.rejects(v.verify(fresh(fixture.now * 1000), METHOD), e => e.code === Reason.StampWindow); +}); + +// Outside the run named exactly "local", a store that does not declare itself +// the durable one every replica shares is refused at startup, as Go refuses it. +test('startup requires a shared store outside exactly local', () => { + try { + for (const environment of [undefined, '', 'production', 'staging', 'local-production', 'local-docker', ' local', 'local ', 'local']) { + if (environment === undefined) delete process.env.CODEFLY__ENVIRONMENT; + else process.env.CODEFLY__ENVIRONMENT = environment; + const build = store => new Verifier(source(), fixture.audience, store); + if (environment === 'local') build(replay()); + else assert.throws(() => build(replay()), e => e.code === Reason.StoreNotShared); + build({ ...replay(), shared: true }); + new Verifier(source(), fixture.audience); // a token-only verifier holds no store + } + } finally { process.env.CODEFLY__ENVIRONMENT = 'local'; } +}); diff --git a/libraries/authority-client/typescript/work-context.ts b/libraries/authority-client/typescript/work-context.ts index 45addf34..e9e95b1b 100644 --- a/libraries/authority-client/typescript/work-context.ts +++ b/libraries/authority-client/typescript/work-context.ts @@ -1,15 +1,9 @@ -/** Authenticate the Work Context the hop forwarded to a Node module, then read it. - * - * `Verifier` is the Go library's `hop.Verifier`, and `authenticateClaims` is - * `workcontext.Authenticate`, rule for rule: the stamp binds exactly one token to - * this run's proof by its digest; the token passes Core's decode path (size, - * schema, unknown fields, canonical encoding, lifetime), the issuer pin, the key - * (never Core's public conformance key), the Ed25519 signature, the audience, the - * window, and the chain's attenuation and grant shape. The schema rules are the - * contract's own buf.validate declarations, run by protovalidate. The shared - * `testdata/vectors.json`, written by the Go test, holds every rule to Go's - * verdict. Nothing here consumes a nonce: liveness is the hop's Check, which the - * digest binds to this call. */ +import { admissionKeys, checkCall, Expired, Replayed, signedBy, SKEW, type ReplayStore } from "./hop-call.js"; +export { Expired, Replayed, LIFETIME, SKEW, PURPOSE } from "./hop-call.js"; +export type { ReplayStore } from "./hop-call.js"; +/** Work Context identity readers and method-bound, single-use hop admission. + * Verifier consumes signed call nonces in a required durable shared ReplayStore. + * authenticateToken alone authenticates identity, not admission. */ import { createHash, createPrivateKey, createPublicKey, timingSafeEqual, verify } from "node:crypto"; import { equals, fromBinary, toBinary } from "@bufbuild/protobuf"; import { createValidator } from "@bufbuild/protovalidate"; @@ -26,57 +20,8 @@ const FIXTURE_PUBLIC = Buffer.from(createPrivateKey({ format: "der", type: "pkcs8", }).export({ format: "jwk" }).x as string, "base64url"); -export enum Reason { - Configuration = "configuration", - StampProof = "stamp_proof", - StampToken = "stamp_token", - StampDigest = "stamp_digest", - StampIdentity = "stamp_identity", - KeysUnavailable = "keys_unavailable", - Cancelled = "cancelled", - Malformed = "malformed", - Lifetime = "lifetime", - Issuer = "issuer", - UnknownKey = "unknown_key", - InvalidKey = "invalid_key", - FixtureKey = "fixture_key", - Signature = "signature", - Audience = "audience", - NotYetValid = "not_yet_valid", - Expired = "expired", - Attenuation = "attenuation", - Grant = "grant", -} -const reasonText: Record = { - [Reason.Configuration]: "the verifier configuration is incomplete", - [Reason.StampProof]: "the call requires this run's hop proof", - [Reason.StampToken]: "the stamp binds no single Work Context", - [Reason.StampDigest]: "the Work Context does not match the hop stamp", - [Reason.StampIdentity]: "the stamp does not match the authenticated Work Context", - [Reason.KeysUnavailable]: "the authority's published keys are unavailable", - [Reason.Cancelled]: "the call ended while the authority's keys were being read", - [Reason.Malformed]: "the Work Context encoding or claims are invalid", - [Reason.Lifetime]: "the capability lifetime exceeds the allowed bound", - [Reason.Issuer]: "the Work Context was issued by an untrusted issuer", - [Reason.UnknownKey]: "no verification key is published for this Work Context", - [Reason.InvalidKey]: "the authority's verification key is malformed", - [Reason.FixtureKey]: "the conformance fixture key is refused", - [Reason.Signature]: "the signature does not verify under the published key", - [Reason.Audience]: "the Work Context was minted for another audience", - [Reason.NotYetValid]: "the Work Context is not valid before its not-before instant", - [Reason.Expired]: "the Work Context has expired", - [Reason.Attenuation]: "the actor chain widens authority", - [Reason.Grant]: "the grant structure is invalid", -}; -/** Fixed caller-safe code and text; never retains a diagnostic cause. */ -export class Unauthenticated extends Error { - override name = "Unauthenticated"; - readonly text: string; - constructor(readonly code: Reason) { - super(`${code}: ${reasonText[code]}`); - this.text = reasonText[code]; - } -} +export { Reason, Unauthenticated } from "./refusal.js"; +import { Reason, Unauthenticated } from "./refusal.js"; /** The authority a module believes: its issuer and published keys, from Keys. */ export interface Trust { @@ -87,11 +32,13 @@ export interface Trust { readonly now?: () => number; } -/** Trust from the authority interface's KeysResponse: Ed25519 keys only. */ +/** Trust from the authority interface's KeysResponse: Ed25519 Work Context + * keys only (purpose "work-context", or none from a host older than the + * purpose). The hop's admission keys are never among them. */ export function trustFromKeys(response: KeysResponse, now?: () => number): Trust { const keys = new Map(); for (const key of response.keys) { - if (key.algorithm !== "Ed25519") continue; + if (key.algorithm !== "Ed25519" || (key.purpose !== "" && key.purpose !== "work-context")) continue; if (!key.keyId || keys.has(key.keyId) || key.publicKey.length !== 32) { throw new Unauthenticated(Reason.InvalidKey); } @@ -124,14 +71,19 @@ export interface Claims { /** Server-side request headers; repeated values must not be discarded. */ export type StampHeaders = Readonly> | Iterable; -/** The claims of the Work Context the hop forwarded to this module (audience), or - * Unauthenticated naming why it must not be believed. */ -export function authenticateClaims(headers: StampHeaders, proof: string, audience: string, trust: Trust): Claims { - if (!audience) throw new Unauthenticated(Reason.Configuration); - return authenticateToken(boundToken(headers, proof), audience, trust); -} - -/** Authenticate bare bytes without claiming hop admission or live checks. */ +/** The claims of one Work Context, for this module (audience), or + * Unauthenticated naming why they must not be believed. + * + * This is the one identity-only reader here, and identity is all it + * establishes. It proves no hop admission: it does not check that a call was + * admitted for the procedure it arrived on, consumes no single-use nonce, and + * asks the authority nothing, so it checks no live revocation either. A + * service admits a call with Verifier.verify. + * + * A caller holding a call's headers extracts the token the stamp's digest binds + * with boundToken first. That is deliberately two calls (security review r164 + * R4): a reader taking headers reads as though it admitted the call, and it + * does not. */ export function authenticateToken(token: string, audience: string, trust: Trust): Claims { if (!audience) throw new Unauthenticated(Reason.Configuration); if (token.length > MAX_TOKEN_BYTES || token.split(".").length !== 2) { @@ -246,10 +198,10 @@ function checkStructure(context: WorkContextV1): void { if (identityKey(context, last) !== actor) throw new Unauthenticated(Reason.Grant); } -/** The one token the stamp's digest binds under this run's proof. Bound, not - * authenticated: whoever holds the proof can write both. */ -export function boundToken(headers: StampHeaders, proof: string): string { - if (headers === null || typeof headers !== "object" || typeof proof !== "string") { +/** Extract one digest-bound token. This is not hop admission authentication: + * checkCall and a shared ReplayStore establish that, normally via Verifier. */ +export function boundToken(headers: StampHeaders): string { + if (headers === null || typeof headers !== "object") { throw new Unauthenticated(Reason.Configuration); } const values = new Map(); @@ -264,9 +216,6 @@ export function boundToken(headers: StampHeaders, proof: string): string { return left.length === right.length && timingSafeEqual(left, right); }; const token = one("x-codefly-work-context"); - if (!proof || !equal(proof, one("x-codefly-hop-proof"))) { - throw new Unauthenticated(Reason.StampProof); - } if (!token || values.get("x-codefly-hop-context")?.length !== 1) throw new Unauthenticated(Reason.StampToken); if (!equal(createHash("sha256").update(token).digest("hex"), one("x-codefly-hop-context"))) { throw new Unauthenticated(Reason.StampDigest); @@ -334,59 +283,165 @@ export interface KeySource { * re-read are trusted no longer than 15 min. */ const KEY_RETRY_MS = 10_000, KEY_MAX_AGE_MS = 300_000, KEY_STALE_MS = 900_000, KEY_TIMEOUT_MS = 5_000; +/** What keysError reports, for the module's logs, while the authority's keys + * read but publish no hop admission key: every call is refused as + * keys_unavailable until the authority publishes its hop's key. */ +export class NoAdmissionKeys extends Error { + override name = "NoAdmissionKeys"; + constructor() { super("the authority's Keys publish no hop admission key (admission_keys), so no call can be admitted"); } +} + /** The one module-side entry point: authenticates every call the hop forwards. * Build it once at startup with the generated client of the authority interface - * the module depends on; the keys are read through its Keys, never typed in. - * Construction never fails on an authority that is not serving yet: calls are - * refused, with the reason, until its keys are read. */ + * the module depends on; the keys, the Work Context's and the hop's admission + * keys, are read through its Keys, never typed in. Without a replay store it + * authenticates bare tokens only and admits no call. Construction never fails + * on an authority that is not serving yet: calls are refused, with the reason, + * until its keys are read. */ export class Verifier { private trust?: Trust; + /** The published hop admission keys, hex, read with trust. */ + private admission: string[] = []; private fetched = 0; private tried = Number.NEGATIVE_INFINITY; private inflight?: Promise; /** The last key-read failure, for the module's own logs; never sent to a caller. */ keysError?: unknown; - constructor(private readonly source: KeySource, private readonly audience: string, private readonly proof?: string, + constructor(private readonly source: KeySource, private readonly audience: string, + private readonly replay?: ReplayStore, private readonly now: () => number = Date.now, private readonly timeoutMs: number = KEY_TIMEOUT_MS) { - if (!source || !audience || proof === "") throw new Unauthenticated(Reason.Configuration); + if (!source || !audience) throw new Unauthenticated(Reason.Configuration); + // A process-local store answers only for its own process: a captured + // admission would replay once per replica, and a restart would forget every + // reservation. Only the run named exactly "local" may use one. + if (replay && process.env.CODEFLY__ENVIRONMENT !== "local" && replay.shared !== true) { + throw new Unauthenticated(Reason.StoreNotShared); + } void this.refresh(); } /** Whether trusted keys are held: true from the first successful read and * through failed re-reads inside the 15-minute window; false before any read * succeeded or once the keys are stale. Readiness uses this, never keysError. - * It also starts the read a call would (keys missing or older than 5 min, at - * most every 10 s, never waiting), so an unready replica, which gets no calls, + * It also starts the read a call would (keys missing or older than 5 min, or + * no admission key published, at most every 10 s, never waiting), so an unready replica, which gets no calls, * recovers, and an idle one never goes stale. */ ready(): boolean { const now = this.now(); - if ((!this.trust || now - this.fetched > KEY_MAX_AGE_MS) && !this.inflight && now - this.tried >= KEY_RETRY_MS) void this.refresh(); + const unadmitting = this.replay !== undefined && this.admission.length === 0; + if ((!this.trust || now - this.fetched > KEY_MAX_AGE_MS || unadmitting) && !this.inflight && now - this.tried >= KEY_RETRY_MS) void this.refresh(); return this.trust !== undefined && now - this.fetched <= KEY_STALE_MS; } - /** Who the call is for, or Unauthenticated naming why it is refused. */ - async verify(headers: StampHeaders): Promise { + /** Who the call is for, or Unauthenticated naming why it is refused. The + * admission is checked first, and its single-use nonce is reserved before the + * keys are read. The reservation's in-flight hold ends when verify returns, so + * it lasts to the admission's expiry plus the clock allowance; a handler whose + * work follows uses admit, which holds it until that work has ended. */ + async verify(headers: StampHeaders, method: string): Promise { + const admission = await this.begin(headers, method); + await admission.finish(); + return admission.identity; + } + + /** verify that holds the single-use reservation until handler has settled, + * however it settles. Call it as soon as the request's headers have arrived, + * before its body is read. */ + async admit(headers: StampHeaders, method: string, handler: (identity: Identity) => Promise): Promise { + const admission = await this.begin(headers, method); + try { return await handler(admission.identity); } + finally { await admission.finish(); } + } + + private async begin(headers: StampHeaders, method: string): Promise<{ identity: Identity; finish: () => Promise }> { const pairs = [...(Symbol.iterator in Object(headers) ? headers as Iterable : Object.entries(headers))] .flatMap(([key, value]) => (typeof value === "string" ? [value] : [...value]).map(v => [key.toLowerCase(), v] as const)); - const token = boundToken(pairs, this.proof ?? ""); // the stamp first: nothing unbound reaches the key read - const id = await this.verifyToken(token); - const stamped: Record = { - "x-codefly-hop-owner": id.owner, "x-codefly-hop-owner-kind": id.ownerKind, "x-codefly-hop-tenant": id.tenant, - "x-codefly-hop-actors": id.actors.join(","), "x-codefly-hop-scopes": id.scopes.join(","), - "x-codefly-hop-binding": id.binding, "x-codefly-hop-task": id.task, "x-codefly-hop-expires": id.expires > 0 ? String(id.expires) : "", - }; - for (const [key, want] of Object.entries(stamped)) { - const got = pairs.filter(([name]) => name === key).map(([, value]) => value); - if (got.length > 1 || (got[0] ?? "") !== want) { - throw new Unauthenticated(Reason.StampIdentity); + if (this.replay === undefined) { + // A token-only verifier holds no replay store, so it can establish no + // admission. + throw new Unauthenticated(Reason.Configuration); + } + // The admission first, judged at arrival by the published hop admission + // keys held: nothing signed for another procedure, another audience or + // another token reaches the Work Context's key read or starts one. + const arrival = this.now(), mark = performance.now(); + const call = checkCall(pairs, await this.admissionKey(pairs), method, this.audience, arrival); + const finish = await this.reserve(call.nonce, call.expires + SKEW, arrival, mark); + try { + const token = boundToken(pairs); + const id = await this.verifyToken(token); + const stamped: Record = { + "x-codefly-hop-owner": id.owner, "x-codefly-hop-owner-kind": id.ownerKind, "x-codefly-hop-tenant": id.tenant, + "x-codefly-hop-actors": id.actors.join(","), "x-codefly-hop-scopes": id.scopes.join(","), + "x-codefly-hop-binding": id.binding, "x-codefly-hop-task": id.task, "x-codefly-hop-expires": id.expires > 0 ? String(id.expires) : "", + }; + for (const [key, want] of Object.entries(stamped)) { + const got = pairs.filter(([name]) => name === key).map(([, value]) => value); + if (got.length > 1 || (got[0] ?? "") !== want) { + throw new Unauthenticated(Reason.StampIdentity); + } } + // An admission carrying an advisory refusal is refused by checkCall, so an + // admitted call never has one: diagnostic identity is never authority. + return { identity: Object.freeze({ ...id, advisory: "" }), finish }; + } catch (error) { + await finish(); + throw error; + } + } + + /** The published hop admission key the admission is signed by. It is judged + * on the keys held: neither a stamp signed by no published key nor keys past + * KEY_MAX_AGE start a read here, so a caller without an admission can neither + * drive the authority's keys nor delay a genuine call's read. Only when no + * trusted keys are held at all (never read, or past KEY_STALE), or the last + * read published no admission key (the hop had not registered yet), does it + * start one, at most every KEY_RETRY as readiness does, and wait for it. */ + private async admissionKey(pairs: readonly (readonly [string, string])[]): Promise { + const now = this.now(); + const trusted = () => this.trust !== undefined && this.now() - this.fetched <= KEY_STALE_MS; + const judgeable = () => trusted() && this.admission.length > 0; + if (!judgeable() && !this.inflight && now - this.tried >= KEY_RETRY_MS) void this.refresh(); + if (!judgeable() && this.inflight) await this.inflight; + if (!trusted()) { + if (this.keysError instanceof Unauthenticated && this.keysError.code === Reason.InvalidKey) throw new Unauthenticated(Reason.InvalidKey); + throw new Unauthenticated(Reason.KeysUnavailable); } - const advisory = pairs.find(([name]) => name === "x-codefly-hop-advisory")?.[1] ?? ""; - return Object.freeze({ ...id, advisory }); + if (this.admission.length === 0) throw new Unauthenticated(Reason.KeysUnavailable); + const key = signedBy(pairs, this.admission); + if (!key) throw new Unauthenticated(Reason.StampProof); + return key; + } + + /** Consume the admission's nonce, retained to its expiry plus the clock + * allowance at least, and answer the release that ends its in-flight hold. */ + private async reserve(nonce: string, until: number, arrival: number, mark: number): Promise<() => Promise> { + const store = this.replay!; + const managed = typeof store.reserve === "function" && typeof store.finish === "function"; + try { + if (managed) await store.reserve!(nonce, until); + else await store.consume(nonce, until); + } catch (error) { + if (error instanceof Unauthenticated) throw error; + throw new Unauthenticated(error instanceof Replayed ? Reason.StampReplayed : error instanceof Expired ? Reason.StampWindow : Reason.StampStore); + } + // A store without reserve keeps a nonce to its retention and may then evict + // it. A consumption that only succeeded because that retention had passed + // found an evicted nonce, not a new one: it is refused on the window, so a + // second delivery never runs. + if (!managed && arrival + (performance.now() - mark) >= until * 1000) throw new Unauthenticated(Reason.StampWindow); + let released = false; + return async () => { + if (!managed || released) return; + released = true; + // A failed release keeps the reservation held: nothing reopens admission. + try { await store.finish!(nonce); } catch { /* held */ } + }; } - /** Authenticate a bare token with the stamped path's fail-closed key cache. */ + /** Authenticate a bare token with the stamped path's fail-closed key cache. + * It establishes no admission: no method binding, no nonce consumption. */ async verifyToken(token: string): Promise { const claims = authenticateToken(token, this.audience, await this.trustFor(keyIdOf(token))); return Object.freeze({ ...identityOf(claims.context), context: token, claims, advisory: "" }); @@ -416,7 +471,11 @@ export class Verifier { const signal = AbortSignal.timeout(this.timeoutMs); const deadline = new Promise((_, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true })); this.inflight = Promise.race([this.source.keys({}, { timeoutMs: this.timeoutMs, signal }), deadline]) - .then(response => { this.trust = trustFromKeys(response); this.fetched = this.now(); this.keysError = undefined; }) + .then(response => { + const trust = trustFromKeys(response), admission = admissionKeys(response); + this.trust = trust; this.admission = admission; this.fetched = this.now(); + this.keysError = this.replay && admission.length === 0 ? new NoAdmissionKeys() : undefined; + }) .catch(error => { this.keysError = error; }) .finally(() => { this.inflight = undefined; }); return this.inflight; diff --git a/libraries/conformance/go/forwarded_context.go b/libraries/conformance/go/forwarded_context.go index 96b2ed00..ebb193ef 100644 --- a/libraries/conformance/go/forwarded_context.go +++ b/libraries/conformance/go/forwarded_context.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" forwarded "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" "google.golang.org/grpc/metadata" "google.golang.org/protobuf/proto" @@ -22,7 +23,7 @@ import ( // host publishes through Keys is exactly what authenticates what it issues, so // a module that pins those keys accepts the host's context and refuses a // forged, unsigned, misaddressed or expired one, naming the reason. It drives -// the same check every module runs behind the hop (workcontext.Authenticate, +// the same check every module runs behind the hop (workcontext.AuthenticateToken, // under hop.Verifier), never a copy of it. func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) { t.Run("a module authenticates the forwarded context against the published keys", func(t *testing.T) { @@ -41,8 +42,7 @@ func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) at := time.Unix(i64(msg(issued, "context"), "issued_at_unix"), 0).Add(time.Second) trust.Now = func() time.Time { return at } - const proof = "conformance-proof" - if claims, err := forwarded.Authenticate(stamped(token, proof), proof, f.Audience, trust); err != nil || claims.Audience() != f.Audience { + if claims, err := forwarded.AuthenticateToken(bound(t, token), f.Audience, trust); err != nil || claims.Audience() != f.Audience { t.Fatalf("the host's own context, under its published keys, is accepted: %v", err) } payload, _, _ := strings.Cut(token, ".") @@ -71,7 +71,7 @@ func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) "expired": {token, f.Audience, "expired", later}, } { t.Run(name+" is refused by name", func(t *testing.T) { - _, err := forwarded.Authenticate(stamped(probe.token, proof), proof, probe.audience, probe.trust) + _, err := forwarded.AuthenticateToken(bound(t, probe.token), probe.audience, probe.trust) if !errors.Is(err, forwarded.ErrUnauthenticated) || !strings.Contains(err.Error(), probe.reason) { t.Fatalf("want a refusal naming %q: %v", probe.reason, err) } @@ -80,9 +80,21 @@ func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) }) } -// stamped is the incoming metadata of a call the hop let through carrying -// token: the token, this run's proof and the stamp's digest of the token. -func stamped(token, proof string) metadata.MD { +// stamped supplies a digest-bound token to the identity reader. Admission +// requires a signed hop proof and replay consumption, tested in libraries/hop. +// bound is the token a hop's stamp binds, extracted the way a module does: +// stamp.Context first, then AuthenticateToken. The two are separate calls +// because neither of them admits a call (review r164 R4). +func bound(t *testing.T, token string) string { + t.Helper() + out, err := stamp.Context(stamped(token)) + if err != nil { + t.Fatal(err) + } + return out +} + +func stamped(token string) metadata.MD { sum := sha256.Sum256([]byte(token)) - return metadata.Pairs("x-codefly-work-context", token, "x-codefly-hop-proof", proof, "x-codefly-hop-context", hex.EncodeToString(sum[:])) + return metadata.Pairs("x-codefly-work-context", token, "x-codefly-hop-context", hex.EncodeToString(sum[:])) } diff --git a/libraries/conformance/go/go.mod b/libraries/conformance/go/go.mod index 2f1efc49..2b37d00c 100644 --- a/libraries/conformance/go/go.mod +++ b/libraries/conformance/go/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/authority/libraries/conformance/go go 1.27.3 require ( - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/google/uuid v1.6.0 diff --git a/libraries/conformance/go/go.sum b/libraries/conformance/go/go.sum index 2c0a19dd..a665ca1f 100644 --- a/libraries/conformance/go/go.sum +++ b/libraries/conformance/go/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 h1:QK5HW356hmRNwZJ1LmoDcKGxB0Jslx+EdBfXMHuJlvo= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab h1:2c2+mvN0H0AvfH0gSLR8Iyi/WqP31TX8uE3IV64mpDk= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 h1:7vdTj6aLQVyFf0MWWgUnR/otX1v9xN6AbFKC2cHrQjE= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= diff --git a/libraries/hop/README.md b/libraries/hop/README.md new file mode 100644 index 00000000..fe65119f --- /dev/null +++ b/libraries/hop/README.md @@ -0,0 +1,266 @@ +# Hop admission v1 + +A Work Context authenticates an identity and capability. It does not prove that +this call passed method policy. Every protected listener must use `Verifier` +with the **actual full procedure**, its own audience, a trusted hop public key, +and an explicitly supplied atomic replay store. Metadata supplied by the caller +must never choose the expected method, audience, public key, or replay namespace. + +This is a breaking replacement of the shared `hop/PROOF` protocol. There is no +legacy acceptance path. Go, Python and TypeScript consume the same signed fixture. +`workcontext.AuthenticateToken`, Python `authenticate_token` and TS +`authenticateToken` are the lower-level identity readers, not admission checks: +they do not authorize a method or consume a nonce. There is exactly **one** of +them per language. The header-taking variants are gone with the shared proof +(review r164 R4): a reader that took a call's metadata read as though it +admitted the call, and after the proof's removal it accepted any token plus a +self-computed digest, so a caller fixing a build break by deleting the proof +argument would have lost admission silently. A caller holding metadata now +extracts the bound token with `stamp.Context` / `bound_token` / `boundToken` +first, which is two visible calls and neither of them an admission. + +## Issuance and verification + +After the authority's live `Check` admits the method, the hop writes its identity +metadata and signs a UTF-8 JSON array with Ed25519: + +``` +["codefly-hop-v1", method, audience, token_sha256_hex, nonce_hex, issued_unix, expires_unix] +``` + +`x-codefly-hop-proof` is unpadded canonical base64url(payload), a dot, and +unpadded canonical base64url(signature). The signature covers the original bytes, +not a parsed and re-encoded JSON object. Nonces are 32 cryptographically random +bytes; digests and nonces are lowercase hex. Unix timestamps are integral seconds. +The maximum admission lifetime is 30 seconds, capped by the Work Context expiry. +Duplicate proof, context or digest headers fail. The signing key is distinct +from the authority's Work Context key. + +A two-second clock allowance (`stamp.Skew`, Python and TypeScript `SKEW`) is +applied identically in the three languages: an admission may be issued up to +that far ahead of the verifier's clock, and stays admissible until that far past +its expiry. Being issued further ahead than the allowance has its own reason, +`stamp_issued`, so a verifier running behind is not diagnosed as an expiry. The +signed span (`expires - issued`) is still capped at 30 seconds exactly, so the +allowance cannot mint a longer-lived admission. + +For gRPC and Connect, `method` is `/package.Service/Procedure`, including the +leading slash. HTTP routes use `VERB /actual/path`, not the matched route template. +The hop uses its selected generated route to distinguish Connect from ordinary +HTTP. A verifier must use the same transport's observed procedure. The audience +is the checked module principal, never an upstream address. + +A REST face a module **transcodes into its gRPC or Connect listener** cannot be +admitted behind the hop. The hop signs an http route as `VERB /path`, which is +what it decided and all a published http route tells it, while the listener the +request reaches observes the full procedure; the two can never agree. A module +cannot verify at both layers either, because the second would consume the +admission nonce again and refuse as `stamp_replayed`. Signing the procedure +instead would need the published route to carry its producer's +template-to-procedure mapping, and the `hop-routes` schema has no such field. +So `routing.Validate` refuses such a composition when the hop starts +(`routing.ErrTranscodedREST`) rather than refusing every REST call at runtime. +Publish the module's HTTP face as a service of its own, or reach the procedure +through Connect, which the hop signs correctly. A module serving HTTP natively, +with no gRPC or Connect face on the same service, is unaffected. + +The verifier checks the signature, method, audience, token digest and admission +window before reading authority keys. It then verifies the Work Context signature, +issuer, audience, window and attenuation, and compares identity metadata with the +claims. Advisory policy refusals cannot be signed as admission; legacy warning mode +is refused at hop startup. It atomically reserves +`codefly-hop-v1::` as soon as the admission checks, +before the keys are read and before the request body is read, and holds the +reservation until the handler has returned. Any replay-store error refuses the +call. Invalid stamps do not reserve nonces. + +The nonce is consumed for **every** stamp, including stamps on idempotent Work +Contexts. A single-use Work Context is consumed once by the hop's existing live +Check; downstream consumes the admission nonce, not that Work Context a second +time. A failed/uncertain downstream delivery burns its admission. Retry through +the hop for a fresh decision, and obtain a fresh capability if it was single-use. +A stream is admitted once before handler entry; this preserves streaming without +buffering its body. Expiry bounds starting a call, not the lifetime of its stream. +This is admission replay protection, not transactional exactly-once effects. + +The window is judged exactly once, at the instant the call starts, and not again +while the admitted call runs; the reservation is taken at that instant too. +Python's and TypeScript's verifiers run before the request is read. Go's +interceptors do not: grpc-go decodes a unary request fully before it invokes +the unary interceptor, and the Connect and REST faces buffer the message too. +Judging the window there refused admissions the authority had granted to slow +uploads; consuming the nonce there let a second delivery of one admission, held +back while its body arrived, find the nonce collected after its retention and +run the handler a second time. `Verifier.StatsHandler`, installed with +`grpc.StatsHandler`, records the arrival instant when gRPC tags the call and +starts the admission check and the reservation beside it, so neither waits for +the body; the interceptor awaits them and refuses with the reason. gRPC tags +every call this way on every transport it serves: its own listener, and the +Connect listener a go-grpc module declares, which hands each call to the same +server through `grpc.Server.ServeHTTP`, where a tap handle never runs (review +r3, M1). A server that installs no handler judges and reserves when the +interceptor runs, so nothing is admitted by its absence. + +A single-use admission is consumed exactly once. Its reservation is retained at +least to expiry plus the allowance, and for as long as a call holding it runs: a +store with a completion operation (Go's `hop.ReservationStore`, Python's +`ReservationStore`, TypeScript's `reserve`/`finish`) keeps it until the handler +has returned AND its retention has passed, and refuses a reservation that only +reaches it after its retention (`stamp_window`), so a delayed delivery can never +revive a collected nonce. A store with only `consume` keeps a nonce to its +retention; a consumption that completes after that retention found an evicted +nonce, not a new one, and is refused on the window. Every later delivery of the +admission is refused by name: `stamp_replayed` while the reservation is held, +`stamp_window` after. `Verify`/`verify` end the in-flight hold before returning; +a caller that runs the call's work itself holds it with `Admit` (Go) or `admit` +(Python and TypeScript) until that work has ended. + +## Replicas and durable replay storage + +A stateless signature or request digest cannot reject an identical replay. A +process-local set also fails on another replica or after restart. Therefore the +libraries require a replay-store dependency and have **no implicit memory cache**. +Go reuses Core's `workcontext.ReplayStore`; Python exposes `ReplayStore.consume` +and `consume_async`; TS exposes `ReplayStore.consume`. Python and TS adapters +raise the exported `Replayed` error for duplicate nonces (Go uses Core +`ErrReplayed`); other failures report store unavailability without leaking backend errors. Consumption must be atomic +across every listener accepting the same signing key and audience, fail closed on outage, and +be durable before it returns. A backend may retain entries longer than expiry. +It must not evict an entry while any verifier can still accept it: account for +clock differences and prevent clock rollback. Synchronize deployment clocks. + +`replaypg.New` is the adapter to prefer: a module's own Postgres, where +`INSERT ... ON CONFLICT (nonce) DO NOTHING` over a primary key is the atomic +reservation, conditioned on the retention not having passed by the database's +clock, and the database is already shared by every replica. Its `active` column +marks a running call, for diagnosis; `Collect` removes every reservation more +than `replaypg.CollectMargin` (a minute) past its retention by the database's +clock, running or not, because `Reserve` refuses any reservation past it on that +clock, so a holder that crashed before finishing leaves no permanent row. The +margin is what makes that refusal hold under concurrency: `Reserve`'s clock +check and its insert are one statement but not one instant, and a collection +removing a row the moment its retention passed could commit between them and +let the insert revive the nonce. Codefly +delivers a store, so this needs nothing the platform cannot provide. +`replaypg.Migration` is the table, `Collect` removes reservations a margin past +their retention, and the console composes exactly this +(`modules/console/services/store`). + +Outside a local run, `NewVerifier` now **refuses** a store that does not declare +itself the shared one (`hop.ErrReplayStoreNotShared`), rather than leaving the +rule to documentation; Python and TypeScript refuse it the same way +(`replay_store_not_shared`), reading a store's `shared` attribute. Core's +in-memory store and `hoptest.Replay` never declare it, so a laptop and a test +keep working while a deployment cannot quietly run per-replica. +`CODEFLY__ENVIRONMENT` decides which run this is: only the name exactly `local` +is a local run. An unset or empty environment, and any other name beginning +with `local`, is not. + +`hop.FileReplayStore` implements this contract for a shared durable filesystem +with atomic exclusive creation and file/directory fsync. Nothing in the library +can tell whether the directory it is given is genuinely shared: an absolute path +to per-pod storage (`/tmp`, an `emptyDir`) looks exactly the same, and a +per-replica store lets a captured admission replay once per replica inside its +window. So `NewFileReplayStore` does not declare itself shared and is refused +outside a local run; an operator who knows the mount is one durable volume every +replica shares says so with `NewSharedFileReplayStore`, which is an assertion +about the deployment, not a check. All replicas for an audience must mount the +same directory; per-pod ephemeral storage is not suitable. Reservations +survive process restart and are never automatically removed. An incomplete +reservation is retained after a crash. Operators may collect complete expired +records only once every verifier's clock has passed the recorded expiry; never +collect incomplete records while their key remains trusted. Monitor volume space; +full or unavailable storage refuses admission. Isolate each audience's volume +from other workloads: an unrelated workload must not be able to remove or edit +reservations. Remote adapters likewise expose atomic reservation, not caller- +controlled deletion. Protect the signing seed from every consumer, including +through filesystem and process isolation in local deployments. Other hosts can supply their +existing durable replay implementation through the same Core contract. The +in-memory implementation used in tests is not a replicated deployment backend. + +## Codefly delivery and migration + +Generate the signing seed with Codefly, scoped to the hop service: + +``` +codefly config generate hop-signing SEED --service authority/hop +``` + +The hop reads `Secret("hop-signing", "SEED")`: a 32-byte hex seed, never the old +workspace `hop/PROOF`. Do not deliver the seed to a consumer or derive it from a +previously shared proof. + +### Delivering the verification key + +**The authority publishes it in `Keys`** (`codefly.dev/authority@0.3.2`): +`KeysResponse.admission_keys`, each with `purpose` `hop-admission`, beside the +Work Context keys in `keys` (purpose `work-context`). A verifier (Go, Python, +TypeScript) takes them from the `Keys` read it already makes, caches and +refreshes with a fail-closed staleness policy, so no consumer is configured with +a hop key and none can drift from the seed. They are a field of their own, so a +verifier that reads `keys` alone (an earlier release, or the JSON Web Key Set) +never trusts a hop key to sign a Work Context, and each verifier uses a key for +its own purpose only. **The hop registers its key** with the authority +(`RegisterAdmissionKey`, `codefly.dev/authority@0.3.3`, through +`hop.Registration`): it presents the public half under its own workload +credential, and the authority refuses any other workload by name. A registration +lapses after `AdmissionLease` unless renewed, and the hop renews it while it runs, +so a restarted authority learns the key again at the next renewal and a key no +running hop signs with is withdrawn. The seed never leaves the hop. A run with no +hop publishes none, and an authority that publishes none leaves every admission +refused as `keys_unavailable`, with `ErrNoAdmissionKeys`/`NoAdmissionKeys` in the +module's key error saying why. A verifier whose last read published no admission +key (it read before the hop registered) reads again on the next call or +readiness check, at most once per `keyRetry`, so it admits within seconds of the +registration rather than after the five-minute key age. + +Every published admission key is trusted, so a rotation overlaps: the authority +publishes the new key beside the retiring one, then the hop signs with the new +one, then the retiring key is withdrawn. A stamp signed by no published key is +refused as `stamp_proof` and starts no key read, so a caller without an +admission cannot drive the authority's keys; a verifier learns a new key on the +next read an admitted call or readiness starts, within the five-minute key age. +Publish the new key at least that long before the hop signs with it: start a +hop with the new seed (it registers its key at boot) that long before calls are +routed to it, while a hop with the retiring seed keeps renewing the retiring +key; the retiring key lapses `AdmissionLease` after the last hop signing with +it stops. + +`accounts` adopting this (implementing `RegisterAdmissionKey` and publishing what its hop registers) is +follow-up work in the host; until it does, a cell's modules on this verifier +refuse every admission as `keys_unavailable`. + +`hop-verification/REPLAY_DIRECTORY` is gone. Reservations live in the consumer's +own store (above), because Codefly delivers a store and does not deliver a +shared volume. No address, seed, key or store connection is hard-coded. Python +and Node hosts supply their shared durable replay adapter; Python's async +adapter must not block the event loop. Readiness continues to check authority +key availability; deployment health should also check its replay store. + +Upgrade the hop and consumers together and remove delivery of `hop/PROOF`. +Downstream repositories must pin this breaking version and pass their actual +procedure into `Verify`/`verify`; no method-less overload remains. + +### Adoption order + +The hop stops writing the shared proof the moment this lands, so every module +behind a hop is refused until it adopts. The order is therefore fixed, and +nothing in this repository can sequence it: + +1. `module-document-store` and `module-record-store` adopt the signed admission + and release. Both are pinned by the authority playground + (`playground/workspace.codefly.yaml`, documents 0.1.13 and records 0.5.0) and + both still verify the shared proof, so calls through the hop to them are + refused until those releases exist and the playground repins them. +2. `accounts` publishes the hop's admission key through `Keys` + (`admission_keys`, above). Until it does, modules on a cell refuse every + admission as `keys_unavailable`; nothing is configured by hand. +3. This repository's hop and console upgrade together, and `hop/PROOF` + delivery is removed. + +A composition that upgrades the hop before step 1 keeps serving the console, +whose verifier is in this repository and upgrades with it, and refuses +documents and records. The regression +suite covers the read-to-delete and zero-scope fabricated-stamp probes from the +two independent reviews, cross-audience use, repeated and concurrent admission, +expiry, duplicate headers, store failure, and cross-instance durable reservations. diff --git a/libraries/hop/go/README.md b/libraries/hop/go/README.md index 42d243c8..2df0be7c 100644 --- a/libraries/hop/go/README.md +++ b/libraries/hop/go/README.md @@ -10,27 +10,40 @@ import ( authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" "github.com/codefly-dev/authority/libraries/authority-client/go/host" hop "github.com/codefly-dev/authority/libraries/hop/go" + "github.com/codefly-dev/authority/libraries/hop/go/replaypg" ) -proof, _ := codefly.For(ctx).WorkspaceSecret("hop", "PROOF") +// The reservations: this module's own store. replaypg.Migration is its table. +// NewVerifier refuses a store that is not the shared one outside a local run. +pool, err := pgxpool.New(ctx, connection) // Service("store").Secret("postgres", "read-write-connection") +replay, err := replaypg.New(pool) conn, _, err := host.Dial(ctx) // the local authority on a laptop, the host on a cell audience, err := hop.Audience() // the module the composition started this process as (CODEFLY__MODULE) -verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, proof) +// The hop's admission keys come with the authority's Keys: nothing is configured. +verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, replay) config.GRPCServerOptions = append(config.GRPCServerOptions, + // Judges the admission window and reserves its single-use nonce when each + // call reaches the server, on the gRPC and the Connect listener alike, not + // after a large request body has decoded. + grpc.StatsHandler(verifier.StatsHandler()), grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()), grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor())) // in a handler: id, _ := hop.FromContext(ctx); id.Tenant, id.Has(kind, action), id.Claims.Allows(kind, action, resourceID) -// an HTTP module: id, err := verifier.Verify(ctx, metadataFromHeaders) +// an HTTP module, once the headers arrive and before reading the body: +// id, done, err := verifier.Admit(ctx, metadataFromHeaders, observedFullProcedure); defer done() ``` -`Verify` checks this run's proof and that the stamp's digest binds the one -forwarded token first (a call without them never reaches the keys), and then the token itself (Core's `Inspector`): the issuer pin, -the Ed25519 signature under the authority's published key, the audience is this -module, not-before and expiry, structure and attenuation. Who the call is for is -read from the authenticated claims (`IdentityOf`); a stamp that says otherwise -is refused, naming the field. It re-checks no permission the hop decided and -consumes nothing: liveness (revision, seal, single use) is the authority's Check -the hop made, bound to this call by the digest. +`Verify` authenticates the signed method, audience, token digest and 30-second +admission window (with a two-second clock allowance, `stamp.Skew`, judged at the +instant the call arrived), authenticates the Work Context, compares identity +metadata, and atomically reserves the call nonce in the shared replay store +before the keys are read. `Admit` does the same and holds the reservation until +the caller's work has ended, as the interceptors do for a handler. It +refuses replays across replicas without re-spending the Work Context's single +use. A process that receives no hop calls authenticates a bare Work Context with +`NewTokenVerifier` and `VerifyToken` instead, which establishes identity and no +admission. +See the [wire protocol and migration contract](../README.md). The keys come from the interface's `Keys`, through the generated client: never typed in, never mounted. A read runs on its own, detached from every call and @@ -51,10 +64,6 @@ authority serves keys, and an idle one never goes stale. Never gate readiness on `KeysError()`: a brief authority outage would take every replica out of rotation while its keys are still trusted. -The proof is shared by every hop-fronted module: it keeps out a caller that is -not a composed workload, and it does not show that a composed workload came -through the hop (codefly-dev/authority#5). - `hoptest` is a real authority for a module's own tests: `hoptest.New(t)` signs with a fresh key and answers `Keys`; `Issue` mints a Work Context and `hoptest.Forward` stamps it exactly as the hop does. @@ -87,8 +96,8 @@ Validate this library with `go test -race ./...` and `go vet ./...` from this directory. Issuer unit tests in the local authority exercise the actual installation-based tenant resolution. -The Go dependency pins authority-client at the commit that introduced -`workcontext.Authenticate`; standalone library tests need no workspace replacement. +The Go dependency pins the matching signed-admission authority-client; +standalone library tests need no workspace replacement. `../testdata/stamped.json` is a genuinely signed call as the hop stamps it; the Python verifier (`codefly_hop`, shipped in `libraries/authority-client/python`) reads it back. Regenerate with `go test -run Fixture -update-fixture`. @@ -108,7 +117,14 @@ values. These errors never retain underlying key-source errors or claim values. `KeysError` / `keys_error` / `keysError` is a separate internal diagnostic, not a caller response. Unknown future Core diagnostic categories remain refused. -Processes that receive only Issue responses use Go `NewTokenVerifier` or omit -`proof` when constructing Python/TypeScript `Verifier`. They still authenticate -bare tokens against published Keys; stamped verification is refused without a -configured run proof. A process receiving hop calls continues to require it. +Admission refusals carry their own codes, in the same scheme: `stamp_proof` (no +admission signed for this call), `stamp_token`, `stamp_digest`, `stamp_method`, +`stamp_audience`, `stamp_window`, `stamp_advisory`, `stamp_replayed` and +`stamp_store`. Each names no proof, token, digest, nonce or claim. + +Processes that receive only Issue responses use Go `NewTokenVerifier`, or omit +`replay` when constructing the Python/TypeScript `Verifier`. They still +authenticate bare tokens against published Keys. They admit no stamped call: +without a shared replay store an admission cannot be consumed once, so `verify` +refuses with `configuration`. A process that receives hop calls supplies one; +the hop's admission keys come from the same Keys read, never from configuration. diff --git a/libraries/hop/go/admission.go b/libraries/hop/go/admission.go new file mode 100644 index 00000000..7ecf1b48 --- /dev/null +++ b/libraries/hop/go/admission.go @@ -0,0 +1,187 @@ +package hop + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" + core "github.com/codefly-dev/core/workcontext" + "google.golang.org/grpc/metadata" +) + +// ReservationStore is a replay store that can hold a single-use admission for +// as long as a call carrying it runs, not only until a fixed instant. The +// verifier reserves at the call's arrival and finishes when its handler +// returns; the store keeps the reservation until Finish AND retainUntil have +// both passed. A store without these operations keeps a nonce to retainUntil, +// and the verifier refuses any consumption that completes past it. +// +// Reserve must refuse atomically, across every replica, a nonce that is already +// held (core.ErrReplayed), including one held by a call still running past +// retainUntil. It must also refuse, at the point the store decides, a +// reservation whose retainUntil has already passed (refusal.StampWindow): a +// reservation delayed past its own horizon must never revive a nonce that +// collection removed. A Finish that fails leaves the reservation held, so a +// failure never reopens admission. Because Reserve refuses on the store's own +// clock any reservation past retainUntil, a store may collect a reservation +// once retainUntil has passed by that clock whether or not its call has +// finished: nothing can reserve that nonce again. replaypg.Store implements +// it, and collects so, so a holder that stops before Finish leaves no +// permanent row. +type ReservationStore interface { + core.ReplayStore + Reserve(ctx context.Context, nonce string, retainUntil time.Time) error + Finish(ctx context.Context, nonce string) error +} + +// instant is when a call arrived: on the verifier's clock, and as a monotonic +// mark from which the time since then is measured. +type instant struct{ at, mark time.Time } + +func (v *Verifier) instant() instant { return instant{at: v.now(), mark: time.Now()} } + +// since is the verifier's clock now, measured from the arrival. +func (i instant) since() time.Time { return i.at.Add(time.Since(i.mark)) } + +// storeTimeout bounds one reservation or release, which run detached from the +// call so that their outcome is always known, even for a call that ends. +const storeTimeout = 5 * time.Second + +// reserve checks the admission md carries against the instant the call arrived +// and consumes its single-use nonce. It returns the release that ends the +// reservation's in-flight hold, safe to call more than once. +func (v *Verifier) reserve(ctx context.Context, md metadata.MD, method string, arrival instant) (func(), error) { + if v.replay == nil { + // A token-only verifier (NewTokenVerifier) holds no replay store, so + // it can establish no admission. + return nil, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Configuration) + } + // The admission is judged at the instant the call arrived, whenever the + // published key it is signed by is read. + key, err := v.admissionKey(ctx, md) + if err != nil { + return nil, err + } + call, err := stamp.Check(md, key, method, v.audience, arrival.at) + if err != nil { + return nil, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, err) + } + nonce := call.ReplayKey(key) + // Retained to expiry plus the clock allowance at least, so a lagging + // verifier never admits a second delivery of a nonce another released. + until := time.Unix(call.Expires+int64(stamp.Skew/time.Second), 0) + release := func() {} + reserving, cancel := context.WithTimeout(context.WithoutCancel(ctx), storeTimeout) + defer cancel() + if managed, ok := v.replay.(ReservationStore); ok { + err = managed.Reserve(reserving, nonce, until) + var once sync.Once + release = func() { + once.Do(func() { + finishing, cancel := context.WithTimeout(context.Background(), storeTimeout) + defer cancel() + // A failed release leaves the reservation held: never reopen. + _ = managed.Finish(finishing, nonce) + }) + } + } else { + // A store with no completion operation keeps the nonce to its + // retention and may then evict it. A consumption that only succeeded + // because that retention had passed found an evicted nonce, not a new + // one, and is refused on the window: a second delivery never runs. + err = v.replay.Consume(reserving, nonce, until) + if err == nil && !arrival.since().Before(until) { + err = refusal.StampWindow + } + } + if err != nil { + reason := refusal.StampStore + switch { + case errors.Is(err, core.ErrReplayed): + reason = refusal.StampReplayed + case errors.Is(err, refusal.StampWindow): + reason = refusal.StampWindow + } + return nil, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, reason) + } + return release, nil +} + +// establish authenticates the Work Context an admitted call carries. A +// refusal releases the reservation's hold: no handler will run for it. +func (v *Verifier) establish(ctx context.Context, md metadata.MD, release func()) (Identity, error) { + id, err := v.VerifyToken(ctx, first(md, WorkContextKey)) + if err == nil { + err = sameAsStamped(md, id) + } + if err != nil { + release() + return Identity{}, err + } + // An admission carrying an advisory refusal is refused by stamp.Check, so + // an admitted call never has one: diagnostic identity is never authority. + return id, nil +} + +type admissionKey struct{} + +// admission is one call's single-use reservation, from the instant the call +// arrived to the instant its handler returned. +type admission struct { + verifier *Verifier + method string + md metadata.MD + reserved chan struct{} // closed once release and err are set + release func() + err error + + mu sync.Mutex + running, ended bool +} + +// start claims the admission for the handler, unless the call already ended. +func (a *admission) start() bool { + a.mu.Lock() + defer a.mu.Unlock() + if a.running || a.ended { + return false + } + a.running = true + return true +} + +// abandon releases the hold of a call that ended before its handler started, +// such as one cancelled while its body was arriving. A call whose handler +// started is released when the handler returns, never by its cancellation. +func (a *admission) abandon() { + a.mu.Lock() + if a.running || a.ended { + a.mu.Unlock() + return + } + a.ended = true + a.mu.Unlock() + <-a.reserved + if a.err == nil { + a.release() + } +} + +// finish releases the hold once the handler has returned. +func (a *admission) finish() { + a.mu.Lock() + a.ended = true + a.mu.Unlock() + a.release() +} + +func finishAdmission(ctx context.Context) { + if a, ok := ctx.Value(admissionKey{}).(*admission); ok { + a.finish() + } +} diff --git a/libraries/hop/go/arrival_test.go b/libraries/hop/go/arrival_test.go new file mode 100644 index 00000000..27828286 --- /dev/null +++ b/libraries/hop/go/arrival_test.go @@ -0,0 +1,160 @@ +package hop + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + core "github.com/codefly-dev/core/workcontext" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/stats" +) + +const arrivalMethod = "/test.Service/Read" + +// arrive is gRPC tagging a call that reached the server carrying md, as it +// does on every transport before the call's body is read. It returns once the +// reservation started at arrival has been decided, so the test's clock moves +// only after it. +func arrive(v *Verifier, md metadata.MD) context.Context { + ctx := v.StatsHandler().TagRPC(metadata.NewIncomingContext(context.Background(), md), &stats.RPCTagInfo{FullMethodName: arrivalMethod}) + <-ctx.Value(admissionKey{}).(*admission).reserved + return ctx +} + +func arrivalVerifier(t *testing.T, clock *time.Time) (*Verifier, signer) { + t.Helper() + s := newSigner("arrival") + source := &keys{response: &authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}}} + v, err := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + if err != nil { + t.Fatal(err) + } + v.now = func() time.Time { return *clock } + v.fetched, v.tried = *clock, *clock + return v, s +} + +// Review r164 B3: grpc-go decodes a unary request fully before it runs the +// unary interceptor, so a slow or large upload reached Verify long after the +// hop admitted it and was refused for an expiry that had not happened at call +// start. The window is judged at the instant the stream was accepted, which +// the stats handler records. +func TestTheAdmissionWindowIsJudgedWhenTheCallArrivesNotAfterItsBodyDecodes(t *testing.T) { + clock := time.Unix(1792818000, 0) + v, s := arrivalVerifier(t, &clock) + md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") + + // The stream is accepted now; its body then takes two minutes to arrive, + // well past stamp.Lifetime. + ctx := arrive(v, md) + clock = clock.Add(2 * time.Minute) + reached := false + if _, err := v.UnaryServerInterceptor()(ctx, nil, &grpc.UnaryServerInfo{FullMethod: arrivalMethod}, func(context.Context, any) (any, error) { + reached = true + return nil, nil + }); err != nil || !reached { + t.Fatalf("a call admitted at arrival was refused after its body decoded: %v", err) + } +} + +// Nothing is admitted by the absence of the stats handler: a server that +// installs none judges the window at the instant Verify runs, as it did before. +func TestWithoutTheStatsHandlerTheWindowIsJudgedWhenVerifyRuns(t *testing.T) { + clock := time.Unix(1792818000, 0) + v, s := arrivalVerifier(t, &clock) + md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") + clock = clock.Add(2 * time.Minute) + _, err := v.Verify(context.Background(), md, arrivalMethod) + var reason refusal.Reason + if !errors.As(err, &reason) || reason != refusal.StampWindow { + t.Fatalf("want %s without the stats handler: %v", refusal.StampWindow, err) + } +} + +// The window the stats handler opens is the admission's own, not an unbounded one: +// a call accepted further ahead than the clock allowance, or long after the +// admission expired, is still refused, each under its own reason. +func TestTheArrivalInstantDoesNotWidenTheAdmissionWindow(t *testing.T) { + for _, tc := range []struct { + name string + shift time.Duration + want refusal.Reason + }{ + {"accepted further ahead than the allowance", -stamp.Skew - 5*time.Second, refusal.StampIssued}, + {"accepted long after it expired", stamp.Lifetime + stamp.Skew + time.Second, refusal.StampWindow}, + } { + t.Run(tc.name, func(t *testing.T) { + clock := time.Unix(1792818000, 0) + v, s := arrivalVerifier(t, &clock) + md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") + clock = clock.Add(tc.shift) + ctx := arrive(v, md) + _, err := v.UnaryServerInterceptor()(ctx, nil, &grpc.UnaryServerInfo{FullMethod: arrivalMethod}, func(context.Context, any) (any, error) { + return nil, nil + }) + // The interceptor answers a gRPC status, which carries the reason + // as its text rather than a wrapped error. + if err == nil || !strings.Contains(err.Error(), tc.want.Code()) { + t.Fatalf("want %s: %v", tc.want, err) + } + }) + } +} + +// A reservation outlives the admission by the same allowance, so a shared store +// evicting on its own clock cannot release a nonce a lagging verifier admits. +func TestAReservationIsRetainedPastExpiryByTheClockAllowance(t *testing.T) { + clock := time.Unix(1792818000, 0) + v, s := arrivalVerifier(t, &clock) + store := &retention{} + v.replay = store + md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") + call, err := stamp.Check(md, testSigner("proof").PublicKey(), arrivalMethod, "documents", clock) + if err != nil { + t.Fatal(err) + } + if _, err := v.Verify(context.Background(), md, arrivalMethod); err != nil { + t.Fatal(err) + } + if want := time.Unix(call.Expires+int64(stamp.Skew/time.Second), 0); !store.until.Equal(want) { + t.Fatalf("retained until %v, want the admission's expiry plus the allowance %v", store.until, want) + } +} + +type retention struct{ until time.Time } + +func (r *retention) Consume(_ context.Context, _ string, until time.Time) error { + r.until = until + return nil +} + +// accepting is a store that has evicted every nonce: each consumption succeeds. +type accepting struct{} + +func (accepting) Consume(context.Context, string, time.Time) error { return nil } + +// A store with no completion operation keeps a nonce only to its retention. A +// delivery whose consumption completes past that retention found an evicted +// nonce, so it is refused on the window rather than run a second time. +func TestAConsumptionCompletingPastItsRetentionIsRefused(t *testing.T) { + clock := time.Unix(1792818000, 0) + v, s := arrivalVerifier(t, &clock) + v.replay = accepting{} + md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") + if _, err := v.reserve(context.Background(), md, arrivalMethod, instant{at: clock, mark: time.Now()}); err != nil { + t.Fatalf("a delivery consumed inside its retention was refused: %v", err) + } + // Arrived inside the window, but its consumption completed a minute later. + _, err := v.reserve(context.Background(), md, arrivalMethod, instant{at: clock, mark: time.Now().Add(-time.Minute)}) + var reason refusal.Reason + if !errors.As(err, &reason) || reason != refusal.StampWindow { + t.Fatalf("want %s for a consumption past its retention: %v", refusal.StampWindow, err) + } +} diff --git a/libraries/hop/go/bare_test.go b/libraries/hop/go/bare_test.go index 2a4bdbed..5b667319 100644 --- a/libraries/hop/go/bare_test.go +++ b/libraries/hop/go/bare_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "errors" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + core "github.com/codefly-dev/core/workcontext" "strings" "testing" "time" @@ -19,7 +20,7 @@ func TestVerifyTokenUsesPublishedTrustAndSafeReasons(t *testing.T) { t.Fatal(err) } token := signer.issue(t, now, "module") - if _, err := v.Verify(t.Context(), forwarded(t, token, "proof", "")); err == nil { + if _, err := v.Verify(t.Context(), forwarded(t, token, "proof", ""), "/test.Service/Read"); err == nil { t.Fatal("bare-only verifier accepted a stamp") } id, err := v.VerifyToken(t.Context(), token) @@ -54,7 +55,7 @@ func TestBareTokenWaitsForStaleRefreshAndRefusesFailure(t *testing.T) { now := time.Now() signer := newSigner("bare-refresh") source := &keys{response: &authorityv1.KeysResponse{Issuer: signer.issuer, Keys: []*authorityv1.PublicKey{signer.published()}}} - v, err := NewVerifier(t.Context(), source, "module", "proof") + v, err := NewVerifier(t.Context(), source, "module", core.NewMemoryReplayStore()) if err != nil { t.Fatal(err) } diff --git a/libraries/hop/go/binding_test.go b/libraries/hop/go/binding_test.go new file mode 100644 index 00000000..c42d51b8 --- /dev/null +++ b/libraries/hop/go/binding_test.go @@ -0,0 +1,121 @@ +package hop_test + +import ( + "context" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + hop "github.com/codefly-dev/authority/libraries/hop/go" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// robin #512 finding 2 and evaluation #91 finding 1: a genuinely signed, +// single-use read capability cannot carry a captured read admission to a write. +func TestReviewReadStampCannotRetargetOrReplayAcrossReplicas(t *testing.T) { + ctx := context.Background() + a := hoptest.New(t) + token := a.IssueSingleUse(t, "documents", read) + const readMethod = "/documents.v1.Service/Get" + md := hoptest.Forward(t, token, proof, readMethod) + store := hoptest.Replay() + v, err := hop.NewVerifier(ctx, a.PublishHop(proof), "documents", store) + if err != nil { + t.Fatal(err) + } + replica, err := hop.NewVerifier(ctx, a.PublishHop(proof), "documents", store) + if err != nil { + t.Fatal(err) + } + for _, method := range []string{"/documents.v1.Service/Delete", "/datasets.v1.Service/Register", "Get", "", "POST " + readMethod} { + if _, err := v.Verify(ctx, md, method); err == nil || !strings.Contains(err.Error(), "stamp_method") { + t.Fatalf("retarget to %q: %v", method, err) + } + } + other, _ := hop.NewVerifier(ctx, a.PublishHop(proof), "records", store) + if _, err := other.Verify(ctx, md, readMethod); err == nil || !strings.Contains(err.Error(), "stamp_audience") { + t.Fatalf("other audience: %v", err) + } + // Refused retargeting does not poison the legitimate reservation. + if _, err := v.Verify(ctx, md, readMethod); err != nil { + t.Fatal(err) + } + for _, verifier := range []*hop.Verifier{v, replica} { + if _, err := verifier.Verify(ctx, md, readMethod); err == nil || !strings.Contains(err.Error(), "stamp_replayed") { + t.Fatalf("replay admitted: %v", err) + } + } +} + +func TestReviewZeroScopeSharedProofCannotFabricateAdmission(t *testing.T) { + a := hoptest.New(t) + token := a.Issue(t, "documents") // genuine zero-scope capability from evaluation probe + md := hoptest.Forward(t, token, proof, "/datasets.v1.Service/Register") + md.Set(hop.ProofKey, proof) // the old shared secret and a matching token digest + if _, err := verifier(t, a, "documents").Verify(context.Background(), md, "/datasets.v1.Service/Register"); err == nil { + t.Fatal("shared proof fabricated admission") + } +} + +func TestConcurrentReplayHasOneWinnerAcrossVerifiers(t *testing.T) { + a := hoptest.New(t) + store := hoptest.Replay() + first, _ := hop.NewVerifier(context.Background(), a.PublishHop(proof), "documents", store) + second, _ := hop.NewVerifier(context.Background(), a.PublishHop(proof), "documents", store) + md := hoptest.Forward(t, a.IssueSingleUse(t, "documents", read), proof, "/documents.v1.Service/Get") + var accepted atomic.Int64 + var wg sync.WaitGroup + for n := range 32 { + wg.Add(1) + go func() { + defer wg.Done() + v := []*hop.Verifier{first, second}[n%2] + if _, err := v.Verify(context.Background(), md, "/documents.v1.Service/Get"); err == nil { + accepted.Add(1) + } + }() + } + wg.Wait() + if accepted.Load() != 1 { + t.Fatalf("accepted %d times", accepted.Load()) + } +} + +func TestUnaryAndStreamInterceptorsBindTheObservedProcedure(t *testing.T) { + a := hoptest.New(t) + v := verifier(t, a, "documents") + md := hoptest.Forward(t, a.Issue(t, "documents", read), proof, "/documents.v1.Service/Get") + ctx := metadata.NewIncomingContext(context.Background(), md) + reached := false + _, err := v.UnaryServerInterceptor()(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/documents.v1.Service/Delete"}, func(context.Context, any) (any, error) { reached = true; return nil, nil }) + if err == nil || reached { + t.Fatal("unary reached retargeted handler") + } + err = v.StreamServerInterceptor()(nil, bindingStream{ctx: ctx}, &grpc.StreamServerInfo{FullMethod: "/documents.v1.Service/Delete"}, func(any, grpc.ServerStream) error { reached = true; return nil }) + if err == nil || reached { + t.Fatal("stream reached retargeted handler") + } +} + +type bindingStream struct { + grpc.ServerStream + ctx context.Context +} + +func (s bindingStream) Context() context.Context { return s.ctx } + +func TestAdvisoryRefusalCannotBecomeASignedAdmission(t *testing.T) { + a := hoptest.New(t) + token := a.Issue(t, "documents", read) + md := metadata.Pairs(hop.WorkContextKey, token) + if err := hop.Stamp(md, hop.Identity{Advisory: "write scope missing"}, hoptest.Signer(proof), "/documents.v1.Service/Delete", "documents", time.Now()); err == nil { + t.Fatal("signed an advisory refusal") + } + if len(md.Get(hop.ProofKey)) != 0 { + t.Fatal("refusal leaked a usable proof") + } +} diff --git a/libraries/hop/go/durable_test.go b/libraries/hop/go/durable_test.go new file mode 100644 index 00000000..ac6ce55c --- /dev/null +++ b/libraries/hop/go/durable_test.go @@ -0,0 +1,80 @@ +package hop_test + +import ( + "context" + "errors" + "testing" + + hop "github.com/codefly-dev/authority/libraries/hop/go" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" + "github.com/codefly-dev/authority/libraries/hop/go/replaypg" + "github.com/codefly-dev/core/resources" + core "github.com/codefly-dev/core/workcontext" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type insertingPool struct{} + +func (insertingPool) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) { + return pgconn.NewCommandTag("INSERT 0 1"), nil +} + +func (insertingPool) QueryRow(context.Context, string, ...any) pgx.Row { return nil } + +// Review r164 B1: the contract forbids a per-replica replay store outside a +// local run, but nothing refused one. A hand-written path to per-pod storage, +// or Core's in-memory store, passed and let a captured admission replay once +// per replica inside its window. The verifier refuses it at startup now. +func TestAReplayStoreThatIsNotSharedIsRefusedOutsideALocalRun(t *testing.T) { + a := hoptest.New(t) + postgres, err := replaypg.New(insertingPool{}) + if err != nil { + t.Fatal(err) + } + directory, err := hop.NewFileReplayStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + mount, err := hop.NewSharedFileReplayStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + build := func(store core.ReplayStore) error { + _, err := hop.NewVerifier(context.Background(), a.PublishHop(proof), "documents", store) + return err + } + + // A laptop and a test keep working with any store, including Core's + // in-memory one, only under the exact local environment name. + for _, environment := range []string{"local"} { + t.Setenv(resources.EnvironmentPrefix, environment) + for name, store := range map[string]core.ReplayStore{ + "in-memory": hoptest.Replay(), "a directory": directory, + "a shared mount": mount, "postgres": postgres, + } { + if err := build(store); err != nil { + t.Fatalf("environment %q refused %s: %v", environment, name, err) + } + } + } + + for _, environment := range []string{"", "staging", "production", "local-docker", "local-production", " local", "local "} { + t.Setenv(resources.EnvironmentPrefix, environment) + for name, store := range map[string]core.ReplayStore{ + "in-memory": hoptest.Replay(), "a directory not stated to be shared": directory, + } { + if err := build(store); !errors.Is(err, hop.ErrReplayStoreNotShared) { + t.Fatalf("environment %q admitted %s: %v", environment, name, err) + } + } + // A store that states it is the one every replica shares is admitted. + for name, store := range map[string]core.ReplayStore{ + "postgres": postgres, "a mount stated to be shared": mount, + } { + if err := build(store); err != nil { + t.Fatalf("environment %q refused %s: %v", environment, name, err) + } + } + } +} diff --git a/libraries/hop/go/fixture_test.go b/libraries/hop/go/fixture_test.go index 8ef4f6f3..e6218f65 100644 --- a/libraries/hop/go/fixture_test.go +++ b/libraries/hop/go/fixture_test.go @@ -4,12 +4,13 @@ import ( "context" "crypto/ed25519" "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "flag" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "os" "path/filepath" - "reflect" "strings" "sync" "testing" @@ -22,6 +23,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/metadata" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" ) var updateFixture = flag.Bool("update-fixture", false, "rewrite ../testdata/stamped.json") @@ -40,7 +42,7 @@ func newSigner(seed string) signer { } func (s signer) published() *authorityv1.PublicKey { - return &authorityv1.PublicKey{KeyId: s.id, Algorithm: core.Algorithm, PublicKey: s.key.Public().(ed25519.PublicKey)} + return &authorityv1.PublicKey{KeyId: s.id, Algorithm: core.Algorithm, PublicKey: s.key.Public().(ed25519.PublicKey), Purpose: workContextPurpose} } func (s signer) issue(t *testing.T, at time.Time, audience string, scopes ...*basev0.WorkScopeV1) string { @@ -49,7 +51,7 @@ func (s signer) issue(t *testing.T, at time.Time, audience string, scopes ...*ba token, _, err := a.Start(context.Background(), core.StartInput{ TenantID: core.FixtureTenant, OwnerPrincipalID: core.FixturePrincipal, OwnerPrincipalKind: "human", OrganizationID: core.FixtureOrganization, TaskID: core.FixtureTask, Audience: audience, - AuthorityScopes: scopes, InstallationID: core.FixtureInstallation, TTL: 10 * time.Minute, + ReplayPolicy: core.ReplaySingleUse, AuthorityScopes: scopes, InstallationID: core.FixtureInstallation, TTL: 10 * time.Minute, }) if err != nil { t.Fatal(err) @@ -66,12 +68,16 @@ func forwarded(t *testing.T, token, proof, advisory string) metadata.MD { id := IdentityOf(decoded.Context()) id.Advisory = advisory md := metadata.Pairs(WorkContextKey, token) - Stamp(md, id, proof) + if err := Stamp(md, id, testSigner(proof), "/test.Service/Read", decoded.Context().GetAudience(), time.Unix(decoded.Context().GetNotBeforeUnix(), 0)); err != nil { + t.Fatal(err) + } return md } // keys is a switchable Keys answer that counts the reads; with hold set, a -// read waits for it to be closed (or for its own context to end). +// read waits for it to be closed (or for its own context to end). A response +// that publishes no hop admission key publishes the test hop's +// (testSigner("proof")), as an authority publishes its hop's key. type keys struct { mu sync.Mutex response *authorityv1.KeysResponse @@ -92,6 +98,10 @@ func (k *keys) Keys(ctx context.Context, _ *authorityv1.KeysRequest, _ ...grpc.C return nil, ctx.Err() } } + if response != nil && len(response.GetAdmissionKeys()) == 0 { + response = proto.Clone(response).(*authorityv1.KeysResponse) + response.AdmissionKeys = []*authorityv1.PublicKey{publishedHop("proof")} + } return response, err } @@ -118,7 +128,6 @@ func settled(v *Verifier) { } type stampFixture struct { - Proof string `json:"proof"` Audience string `json:"audience"` Now int64 `json:"now"` Keys json.RawMessage `json:"keys"` @@ -128,22 +137,22 @@ type stampFixture struct { // The stamp's one contract, for every language a module is written in: the // headers this hop writes for one call carrying a genuinely signed Work -// Context, the published keys that authenticate it at a fixed clock, and the -// identity read from it. The Python verifier (codefly_hop, in libraries/authority-client/python) reads it back. +// Context, the published keys that authenticate it at a fixed clock (the Work +// Context's and the hop's admission key), and the identity read from it. The Python verifier (codefly_hop, in libraries/authority-client/python) reads it back. func TestTheStampFixtureAuthenticatesAsTheHopWroteIt(t *testing.T) { path := filepath.Join("..", "testdata", "stamped.json") s := newSigner("fixture") if *updateFixture { at := time.Unix(1792818000, 0) token := s.issue(t, at, "documents", &basev0.WorkScopeV1{ResourceKind: "documents.collection", Actions: []string{"read"}}, &basev0.WorkScopeV1{ResourceKind: "documents.collection", Actions: []string{"write"}, ResourceIds: []string{"c-1"}}) - md := forwarded(t, token, "fixture-proof", "documents.collection:write is not held") - published, _ := protojson.Marshal(&authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}}) - f := stampFixture{Proof: "fixture-proof", Audience: "documents", Now: at.Add(time.Minute).Unix(), Keys: published, Headers: map[string]string{}} + md := forwarded(t, token, "fixture-proof", "") + published, _ := protojson.Marshal(&authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}, AdmissionKeys: []*authorityv1.PublicKey{publishedHop("fixture-proof")}}) + f := stampFixture{Audience: "documents", Now: at.Unix(), Keys: published, Headers: map[string]string{}} for key, values := range md { f.Headers[key] = values[0] } id := IdentityOf(mustDecode(t, token)) - f.Identity = map[string]any{"owner": id.Owner, "owner_kind": id.OwnerKind, "tenant": id.Tenant, "actors": id.Actors, "scopes": id.Scopes, "binding": id.Binding, "task": id.Task, "expires": id.Expires, "advisory": "documents.collection:write is not held"} + f.Identity = map[string]any{"owner": id.Owner, "owner_kind": id.OwnerKind, "tenant": id.Tenant, "actors": id.Actors, "scopes": id.Scopes, "binding": id.Binding, "task": id.Task, "expires": id.Expires, "advisory": ""} out, _ := json.MarshalIndent(f, "", " ") if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { t.Fatal(err) @@ -161,7 +170,7 @@ func TestTheStampFixtureAuthenticatesAsTheHopWroteIt(t *testing.T) { if err := protojson.Unmarshal(f.Keys, published); err != nil { t.Fatal(err) } - v, err := NewVerifier(context.Background(), &keys{response: published}, f.Audience, f.Proof) + v, err := NewVerifier(context.Background(), &keys{response: published}, f.Audience, core.NewMemoryReplayStore()) if err != nil { t.Fatal(err) } @@ -170,13 +179,11 @@ func TestTheStampFixtureAuthenticatesAsTheHopWroteIt(t *testing.T) { for key, value := range f.Headers { md.Set(key, value) } - id, err := v.Verify(context.Background(), md) + id, err := v.Verify(context.Background(), md, "/test.Service/Read") if err != nil { t.Fatalf("%s no longer authenticates; regenerate with -update-fixture: %v", path, err) } - if want := forwarded(t, id.Context, f.Proof, id.Advisory); !reflect.DeepEqual(want, md) { - t.Fatalf("%s is not what the hop writes; regenerate with -update-fixture", path) - } + if id.Owner != f.Identity["owner"] || id.Tenant != f.Identity["tenant"] || strings.Join(id.Scopes, ",") != "documents.collection:read" || id.Advisory != f.Identity["advisory"] || !id.Claims.Allows("documents.collection", "write", "c-1") { t.Fatalf("identity: %+v", id) } @@ -199,14 +206,14 @@ func TestPublishedKeysAreRereadOnRotationAndExpireWhenUnreadable(t *testing.T) { source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} start := time.Unix(1792818000, 0) clock := start - v, err := NewVerifier(context.Background(), source, "documents", "proof") + v, err := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) if err != nil { t.Fatal(err) } v.now = func() time.Time { return clock } v.tried, v.fetched = start, start verify := func(token string) error { - _, err := v.Verify(context.Background(), forwarded(t, token, "proof", "")) + _, err := v.Verify(context.Background(), forwarded(t, token, "proof", ""), "/test.Service/Read") settled(v) return err } @@ -247,7 +254,7 @@ func TestAnUnauthenticatedCallerNeitherReadsNorStarvesTheKeys(t *testing.T) { first, second := newSigner("first"), newSigner("second") source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} clock := time.Unix(1792818000, 0) - v, _ := NewVerifier(context.Background(), source, "documents", "proof") + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = clock, clock source.set(&authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{second.published()}}, nil, nil) @@ -258,13 +265,13 @@ func TestAnUnauthenticatedCallerNeitherReadsNorStarvesTheKeys(t *testing.T) { clock = clock.Add(keyRetry) before := source.count() for _, md := range []metadata.MD{metadata.Pairs(WorkContextKey, "garbage"), forwarded(t, second.issue(t, clock, "documents"), "another-run", "")} { - if _, err := v.Verify(cancelled, md); err == nil { + if _, err := v.Verify(cancelled, md, "/test.Service/Read"); err == nil { t.Fatal("a call without this run's proof was admitted") } } settled(v) unauthenticatedReads += source.count() - before - if _, err := v.Verify(context.Background(), forwarded(t, second.issue(t, clock, "documents"), "proof", "")); err == nil { + if _, err := v.Verify(context.Background(), forwarded(t, second.issue(t, clock, "documents"), "proof", ""), "/test.Service/Read"); err == nil { genuine++ } settled(v) @@ -281,7 +288,7 @@ func TestAHungReadStallsNoHeldKeyAndACallerGivingUpDelaysNoOne(t *testing.T) { source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published(), second.published()}}} start := time.Unix(1792818000, 0) clock := start - v, _ := NewVerifier(context.Background(), source, "documents", "proof") + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = start, start hold := make(chan struct{}) @@ -289,7 +296,7 @@ func TestAHungReadStallsNoHeldKeyAndACallerGivingUpDelaysNoOne(t *testing.T) { clock = start.Add(keyMaxAge + time.Second) // old keys: a background read starts, and hangs done := make(chan error, 1) go func() { - _, err := v.Verify(context.Background(), forwarded(t, first.issue(t, clock, "documents"), "proof", "")) + _, err := v.Verify(context.Background(), forwarded(t, first.issue(t, clock, "documents"), "proof", ""), "/test.Service/Read") done <- err }() select { @@ -304,14 +311,14 @@ func TestAHungReadStallsNoHeldKeyAndACallerGivingUpDelaysNoOne(t *testing.T) { third := newSigner("third") short, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() - if _, err := v.Verify(short, forwarded(t, third.issue(t, clock, "documents"), "proof", "")); err == nil || !strings.Contains(err.Error(), "ended while") { + if _, err := v.Verify(short, forwarded(t, third.issue(t, clock, "documents"), "proof", ""), "/test.Service/Read"); err == nil || !strings.Contains(err.Error(), "ended while") { t.Fatalf("a caller giving up: %v", err) } source.set(&authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{third.published()}}, nil, nil) close(hold) settled(v) clock = clock.Add(keyRetry) - if _, err := v.Verify(context.Background(), forwarded(t, third.issue(t, clock, "documents"), "proof", "")); err != nil { + if _, err := v.Verify(context.Background(), forwarded(t, third.issue(t, clock, "documents"), "proof", ""), "/test.Service/Read"); err != nil { t.Fatalf("the next read is not delayed by the caller who gave up: %v", err) } } @@ -325,11 +332,11 @@ func TestACallOnKeysPastKeyStaleWaitsForTheReread(t *testing.T) { source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} start := time.Unix(1792818000, 0) clock := start - v, _ := NewVerifier(context.Background(), source, "documents", "proof") + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = start, start clock = start.Add(39 * time.Minute) - if _, err := v.Verify(context.Background(), forwarded(t, first.issue(t, clock, "documents"), "proof", "")); err != nil { + if _, err := v.Verify(context.Background(), forwarded(t, first.issue(t, clock, "documents"), "proof", ""), "/test.Service/Read"); err != nil { t.Fatalf("the first call after an idle stretch is refused while its keys are re-read: %v", err) } if source.count() != 2 { @@ -345,7 +352,7 @@ func TestACallOnKeysPastKeyStaleIsRefusedWhenTheRereadFails(t *testing.T) { source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} start := time.Unix(1792818000, 0) clock := start - v, _ := NewVerifier(context.Background(), source, "documents", "proof") + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.timeout = 100 * time.Millisecond v.fetched, v.tried = start, start @@ -359,7 +366,7 @@ func TestACallOnKeysPastKeyStaleIsRefusedWhenTheRereadFails(t *testing.T) { md := forwarded(t, first.issue(t, clock, "documents"), "proof", "") done := make(chan error, 1) go func() { - _, err := v.Verify(context.Background(), md) + _, err := v.Verify(context.Background(), md, "/test.Service/Read") done <- err }() select { @@ -378,7 +385,7 @@ func TestACallOnKeysPastKeyStaleIsRefusedWhenTheRereadFails(t *testing.T) { source.set(nil, nil, make(chan struct{})) // a read that never answers clock = clock.Add(keyRetry) began := time.Now() - _, err := v.Verify(context.Background(), forwarded(t, first.issue(t, clock, "documents"), "proof", "")) + _, err := v.Verify(context.Background(), forwarded(t, first.issue(t, clock, "documents"), "proof", ""), "/test.Service/Read") if !refused(err) || time.Since(began) < v.timeout { t.Fatalf("a hung re-read refuses with the fixed reason after keyTimeout: %v after %v", err, time.Since(began)) } @@ -390,7 +397,7 @@ func TestACallOnKeysPastKeyStaleIsRefusedWhenTheRereadFails(t *testing.T) { // A read that never answers is bounded by the verifier's own timeout. func TestAReadIsBoundedByItsOwnTimeout(t *testing.T) { source := &keys{hold: make(chan struct{})} - v := &Verifier{source: source, audience: "documents", proof: "proof", now: time.Now, timeout: 20 * time.Millisecond} + v := &Verifier{source: source, audience: "documents", replay: core.NewMemoryReplayStore(), now: time.Now, timeout: 20 * time.Millisecond} v.mu.Lock() done := v.refresh() v.mu.Unlock() @@ -422,7 +429,7 @@ func TestReadyHoldsWhileKeysAreTrusted(t *testing.T) { first := newSigner("first") source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} clock := time.Unix(1792818000, 0) - v, _ := NewVerifier(context.Background(), source, "documents", "proof") + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = clock, clock if !v.Ready() { @@ -448,7 +455,7 @@ func TestAnUnreadyReplicaBecomesReadyByPollingAlone(t *testing.T) { first := newSigner("first") source := &keys{err: errors.New("authority not serving yet")} clock := time.Unix(1792818000, 0) - v, _ := NewVerifier(context.Background(), source, "documents", "proof") + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.tried = clock ready := false @@ -472,7 +479,7 @@ func TestAnIdleReplicaPolledForReadinessStaysReady(t *testing.T) { first := newSigner("first") source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} clock := time.Unix(1792818000, 0) - v, _ := NewVerifier(context.Background(), source, "documents", "proof") + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = clock, clock for minute := 1; minute <= 60; minute++ { @@ -487,3 +494,16 @@ func TestAnIdleReplicaPolledForReadinessStaysReady(t *testing.T) { t.Fatalf("an hour of readiness polls re-read the keys %d times; keyMaxAge says about 12", reads) } } + +// publishedHop is the admission key of the test hop that signs as label, as +// an authority's Keys carries it in admission_keys. +func publishedHop(label string) *authorityv1.PublicKey { + id, public := testSigner(label).Admission() + return &authorityv1.PublicKey{KeyId: id, Algorithm: core.Algorithm, PublicKey: public, Purpose: stamp.Purpose} +} + +func testSigner(label string) *stamp.Signer { + sum := sha256.Sum256([]byte(label)) + signer, _ := stamp.NewSigner(hex.EncodeToString(sum[:])) + return signer +} diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index 1c175c1a..0d9e4ca1 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -3,8 +3,9 @@ module github.com/codefly-dev/authority/libraries/hop/go go 1.27.3 require ( - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026184108-e1966db4fe66 github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 + github.com/jackc/pgx/v5 v5.11.0 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 ) @@ -36,13 +37,19 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect golang.org/x/net v0.60.0 // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/term v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index f78729a1..9a44f054 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/go/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 h1:QK5HW356hmRNwZJ1LmoDcKGxB0Jslx+EdBfXMHuJlvo= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026184108-e1966db4fe66 h1:hUN9kDZaqaTDJROIfQHzXOawIwKtH7mWm4dTwA0P81Q= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026184108-e1966db4fe66/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= @@ -38,6 +38,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -127,6 +129,18 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= @@ -152,16 +166,22 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= @@ -196,6 +216,8 @@ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= @@ -212,10 +234,12 @@ google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= diff --git a/libraries/hop/go/hop.go b/libraries/hop/go/hop.go index 40804254..94d867f0 100644 --- a/libraries/hop/go/hop.go +++ b/libraries/hop/go/hop.go @@ -3,21 +3,26 @@ // // The hop asked the authority's Check, which verified the caller's Work Context // live (revision, seal, single use) and applied the method's policy, then wrote -// who the call is for, this run's proof and the digest of the checked token. +// who the call is for and a signed admission for this call. // The module re-checks no permission the hop decided, but believes nothing it // has not authenticated: Verifier checks the token's signature under the // authority's published keys, its issuer, that it is addressed to this module, // its window and structure, and that it is the token the stamp's digest binds; // who the call is for is read from the authenticated claims, never a header. // -// What the proof does NOT establish: it is one secret every hop-fronted module -// in the run holds. It keeps out a caller that is not a composed workload (a -// person's client on a laptop, where no mesh stands in front of the module). -// It does not show that a composed workload came through the hop: a module -// holding a genuine context addressed to another can call it directly, with -// the proof, past the method policy and the live check, until the context -// expires. Closing that needs a proof per audience or a stamp the hop signs: -// codefly-dev/authority#5. On a cell, the mesh admits only the hop. +// The hop signs each admission with a service-only key. The authority publishes +// its public half in Keys (admission_keys, purpose hop-admission), so verifiers +// take it from the keys they already read, cache and refresh, never from their +// own configuration, and trust every published one while a key rotates. They +// atomically consume the signed nonce in a shared ReplayStore. Method, audience +// and token digest are authenticated; a captured stamp is single-use. +// +// Every refusal a caller is given is a refusal.Reason (Reason here): a stable +// code with fixed text that names no token, claim, key or transport failure. +// Read it with errors.As. A process that receives no hop calls but must still +// authenticate a Work Context it was handed — an Issue response, say — builds a +// NewTokenVerifier and calls VerifyToken: that authenticates identity on the +// same published keys, and establishes no admission. package hop import ( @@ -44,6 +49,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/stats" "google.golang.org/grpc/status" ) @@ -71,9 +77,7 @@ const ( // the digest of the forwarded token. A module reading that token's claims // reads exactly what was checked, and nothing a caller swapped in after. ContextKey = StampPrefix + "context" - // AdvisoryKey carries what the authority would have refused when the hop - // runs permissive (hop/ENFORCEMENT=warn): the call went through, this says - // why it would not have. Empty, or absent, when nothing was refused. + // AdvisoryKey is always refused at admission. Diagnostic identity is never authority. AdvisoryKey = StampPrefix + "advisory" ) @@ -98,8 +102,7 @@ type Identity struct { // Claims are the authenticated claims, for resource-exact questions // (Claims.Allows) the flat Scopes cannot answer. Claims *workcontext.Claims - // Advisory is what the authority would have refused, when the hop let the - // call through anyway (permissive mode); "" when the call was admitted. + // Advisory is diagnostic only. Stamp refuses an identity carrying a refusal. Advisory string } @@ -123,9 +126,15 @@ func Strip(md metadata.MD) { } } -// Stamp writes identity and the run's proof onto md, after stripping whatever -// stamp a caller sent. -func Stamp(md metadata.MD, id Identity, proof string) { +// Stamp writes identity and a signed call admission, stripping caller stamps. +func Stamp(md metadata.MD, id Identity, signer *stamp.Signer, method, audience string, now time.Time) error { + if id.Advisory != "" { + return errors.New("hop: an advisory policy refusal cannot be signed as admission") + } + proof, err := signer.Sign(first(md, WorkContextKey), method, audience, now, id.Expires) + if err != nil { + return err + } Strip(md) md.Set(ProofKey, proof) md.Set(OwnerKey, id.Owner) @@ -148,6 +157,7 @@ func Stamp(md metadata.MD, id Identity, proof string) { if token := first(md, WorkContextKey); token != "" { md.Set(ContextKey, digest(token)) } + return nil } // IdentityOf reads who an authenticated Work Context is for. Its scopes are the @@ -206,7 +216,8 @@ func Audience() (string, error) { } // KeySource is the authority interface's Keys, as its generated client serves -// it: the issuer and the public keys every Work Context is signed with. +// it: the issuer, the public keys every Work Context is signed with, and the +// hop's admission keys. type KeySource interface { Keys(ctx context.Context, in *authorityv1.KeysRequest, opts ...grpc.CallOption) (*authorityv1.KeysResponse, error) } @@ -231,51 +242,94 @@ const ( // so it goes to KeysError, for the module's logs, never to a caller. var errKeysUnavailable = fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.KeysUnavailable) +// ErrNoAdmissionKeys is what KeysError reports, for the module's logs, while +// the authority's keys read but publish no hop admission key: every call is +// refused as keys_unavailable until the authority publishes its hop's key. +var ErrNoAdmissionKeys = errors.New("hop: the authority's Keys publish no hop admission key (admission_keys), so no call can be admitted: the authority must publish its hop's key") + // Verifier is the one module-side entry point: it authenticates every call the // hop forwards before anything reads it. Build it once, at startup. type Verifier struct { source KeySource audience string - proof string + replay core.ReplayStore now func() time.Time timeout time.Duration - mu sync.Mutex - trust workcontext.Trust - fetched time.Time // when trust was read - tried time.Time // when the last read started - inflight chan struct{} // closed when the running read ends; nil when none runs - failure error // the last read's failure, for KeysError + mu sync.Mutex + trust workcontext.Trust + admission []string // the published hop admission keys, hex, read with trust + fetched time.Time // when trust was read + tried time.Time // when the last read started + inflight chan struct{} // closed when the running read ends; nil when none runs + failure error // the last read's failure, for KeysError } -// NewVerifier pins this module's audience and the run's hop proof, and starts -// reading the authority's published keys through the interface's Keys. keys is -// the generated client of the authority the module already depends on -// (authorityv1.NewAuthorityServiceClient over host.Dial): the local authority -// on a laptop, the host on a cell. A key is never typed in or mounted. +// NewVerifier pins this module's audience. Keys is the generated authority +// client: the Work Context keys and the hop's admission keys are both read +// through it, so nothing about the hop is configured here and no signing seed +// is delivered. replay must atomically reserve nonces across all replicas and +// survive restart. // // It waits for that first read while ctx allows, and never fails on it: a // module starts before its authority serves keys, and refuses calls, with the // reason, until they are read. -func NewVerifier(ctx context.Context, keys KeySource, audience, proof string) (*Verifier, error) { - if proof == "" { +func NewVerifier(ctx context.Context, keys KeySource, audience string, replay core.ReplayStore) (*Verifier, error) { + if replay == nil { return nil, refusal.Configuration } - return newVerifier(ctx, keys, audience, proof) + if err := durableOutsideLocal(replay); err != nil { + return nil, err + } + return newVerifier(ctx, keys, audience, replay) +} + +// SharedReplayStore is a replay store that reports whether it is the durable +// reservation shared by every replica that the admission contract requires. +// replaypg.Store and a FileReplayStore opened with NewSharedFileReplayStore +// report true; Core's in-memory store does not implement this at all. +type SharedReplayStore interface { + core.ReplayStore + Shared() bool +} + +// ErrReplayStoreNotShared is the named refusal for a replay store that cannot +// be the one every replica shares. A process-local set answers only for its own +// process, so a captured stamp replays once per replica inside its window, and +// a restart forgets every reservation. The contract has always forbidden that +// outside local; this refuses it at startup instead of documenting it. +// +// It is judged by CODEFLY__ENVIRONMENT, the environment the composition started +// this process in. A local run may use any store, so a laptop and a test keep +// working with Core's in-memory store and hoptest.Replay. +var ErrReplayStoreNotShared = errors.New("hop: this replay store is not the durable store every replica shares, which admission requires outside a local run: a captured admission would replay once per replica, and a restart would forget every reservation. Use replaypg.New over the module's own store, or NewSharedFileReplayStore for a genuinely shared mount") + +// durableOutsideLocal refuses a store that does not declare itself shared, +// unless this process runs in a local environment. +func durableOutsideLocal(replay core.ReplayStore) error { + environment := os.Getenv(resources.EnvironmentPrefix) + if environment == "local" { + return nil + } + if shared, ok := replay.(SharedReplayStore); ok && shared.Shared() { + return nil + } + return fmt.Errorf("%w (environment %s)", ErrReplayStoreNotShared, environment) } // NewTokenVerifier builds a verifier for Issue responses in a process that does -// not receive hop calls. It needs no hop proof. Verify always refuses stamps; -// VerifyToken uses the same key cache and validation as NewVerifier. +// not receive hop calls. It needs no replay store. Verify always refuses stamps, +// because admission cannot be checked without one; VerifyToken uses the same +// key cache and validation as NewVerifier. func NewTokenVerifier(ctx context.Context, keys KeySource, audience string) (*Verifier, error) { - return newVerifier(ctx, keys, audience, "") + return newVerifier(ctx, keys, audience, nil) } -func newVerifier(ctx context.Context, keys KeySource, audience, proof string) (*Verifier, error) { +func newVerifier(ctx context.Context, keys KeySource, audience string, replay core.ReplayStore) (*Verifier, error) { if keys == nil || audience == "" { return nil, refusal.Configuration } - v := &Verifier{source: keys, audience: audience, proof: proof, now: time.Now, timeout: keyTimeout} + v := &Verifier{source: keys, audience: audience, replay: replay, now: time.Now, timeout: keyTimeout} v.mu.Lock() done := v.refresh() v.mu.Unlock() @@ -288,6 +342,8 @@ func newVerifier(ctx context.Context, keys KeySource, audience, proof string) (* // KeysError is the last failure to read the published keys, nil after a // successful read: for the module's logs and readiness, never for a caller. +// A verifier that admits calls also reports ErrNoAdmissionKeys when the keys +// read but publish no hop admission key. func (v *Verifier) KeysError() error { v.mu.Lock() defer v.mu.Unlock() @@ -301,14 +357,15 @@ func (v *Verifier) KeysError() error { // replica out of rotation while its keys are still trusted. // // It also starts the read a call would (keys missing or older than keyMaxAge, -// at most once per keyRetry, never waiting for it): an unready replica gets no -// calls, so without this it would never read again and never become ready, and -// an idle one would go stale. +// or no admission key published, at most once per keyRetry, never waiting for +// it): an unready replica gets no calls, so without this it would never read +// again and never become ready, and an idle one would go stale. func (v *Verifier) Ready() bool { v.mu.Lock() defer v.mu.Unlock() now := v.now() - if (v.fetched.IsZero() || now.Sub(v.fetched) > keyMaxAge) && v.inflight == nil && now.Sub(v.tried) >= keyRetry { + unadmitting := v.replay != nil && len(v.admission) == 0 + if (v.fetched.IsZero() || now.Sub(v.fetched) > keyMaxAge || unadmitting) && v.inflight == nil && now.Sub(v.tried) >= keyRetry { v.refresh() } return !v.fetched.IsZero() && now.Sub(v.fetched) <= keyStale @@ -316,22 +373,45 @@ func (v *Verifier) Ready() bool { // Verify authenticates the call md carries and returns who it is for, from the // authenticated claims. The error wraps workcontext.ErrUnauthenticated and -// names the reason. The stamp is checked first: a call that does not carry this -// run's proof and a bound token never reaches the keys. -func (v *Verifier) Verify(ctx context.Context, md metadata.MD) (Identity, error) { - token, err := stamp.Context(md, v.proof) +// carries a caller-safe refusal.Reason. The admission is checked first: a call +// that does not carry a hop admission signed for this exact procedure and +// audience, binding this token, never reaches the keys. +// +// Verify consumes the admission and ends its in-flight hold before returning, +// so the reservation lasts to the admission's expiry plus the clock allowance. +// A caller that runs work for the call afterwards uses Admit, which holds the +// reservation until that work has finished. +func (v *Verifier) Verify(ctx context.Context, md metadata.MD, method string) (Identity, error) { + id, done, err := v.Admit(ctx, md, method) if err != nil { - return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, err) + return Identity{}, err } - id, err := v.VerifyToken(ctx, token) + done() + return id, nil +} + +// Admit is Verify for a caller that runs the call's work itself, such as an +// HTTP module: it reserves the single-use admission before any key read, and +// holds the reservation, past its retention if need be, until done is called. +// Call done once the work has returned, whatever its outcome: +// +// id, done, err := verifier.Admit(ctx, md, procedure) +// if err != nil { return err } +// defer done() +// +// The window is judged now. Call Admit as soon as the request's headers have +// arrived, before its body is read, so a slow body neither fails the window +// nor delays the reservation. +func (v *Verifier) Admit(ctx context.Context, md metadata.MD, method string) (Identity, func(), error) { + release, err := v.reserve(ctx, md, method, v.instant()) if err != nil { - return Identity{}, err + return Identity{}, nil, err } - if err := sameAsStamped(md, id); err != nil { - return Identity{}, err + id, err := v.establish(ctx, md, release) + if err != nil { + return Identity{}, nil, err } - id.Advisory = first(md, AdvisoryKey) - return id, nil + return id, release, nil } // VerifyToken authenticates a bare Work Context using the same published keys @@ -366,22 +446,85 @@ func (v *Verifier) trustFor(ctx context.Context, keyID string) (workcontext.Trus } wait, judgeable := v.inflight, held && now.Sub(v.fetched) <= keyStale v.mu.Unlock() - if !judgeable && wait != nil { - select { - case <-wait: - case <-ctx.Done(): - return workcontext.Trust{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Cancelled) + if !judgeable { + if err := v.await(ctx, wait); err != nil { + return workcontext.Trust{}, err } } v.mu.Lock() defer v.mu.Unlock() + if err := v.trusted(); err != nil { + return workcontext.Trust{}, err + } + return workcontext.Trust{Issuer: v.trust.Issuer, Keys: v.trust.Keys, Now: v.now}, nil +} + +// admissionKey is the published hop admission key the admission md carries is +// signed by. It is judged on the keys held: the admission is what a call has +// not yet proven, so neither a stamp signed by no published key nor keys past +// keyMaxAge start a read here, and a caller without an admission can neither +// drive the authority's keys nor delay the read a genuine call needs. Only +// when no trusted keys are held at all (never read, or past keyStale, as on an +// idle replica's first call), or the last read published no admission key (the +// hop had not registered yet), does it start a read, at most once per keyRetry +// as readiness does, and wait for it. A hop's new key is therefore learnt on +// the next read an admitted call or readiness starts, within keyMaxAge, so an +// authority publishes it that long before its hop signs with it, beside the +// retiring key. +func (v *Verifier) admissionKey(ctx context.Context, md metadata.MD) (string, error) { + v.mu.Lock() + // Holding no published admission key is holding no key to judge by: the + // authority publishes the hop's once the hop registers it, which may be + // after this verifier first read Keys. + untrusted := v.trusted() != nil || len(v.admission) == 0 + if untrusted && v.inflight == nil && v.now().Sub(v.tried) >= keyRetry { + v.refresh() + } + wait := v.inflight + v.mu.Unlock() + if untrusted { + if err := v.await(ctx, wait); err != nil { + return "", err + } + } + v.mu.Lock() + defer v.mu.Unlock() + if err := v.trusted(); err != nil { + return "", err + } + if len(v.admission) == 0 { + return "", errKeysUnavailable + } + key := stamp.SignedBy(md, v.admission) + if key == "" { + return "", fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.StampProof) + } + return key, nil +} + +// await waits for the running read, if any, as long as the caller does. +func (v *Verifier) await(ctx context.Context, read chan struct{}) error { + if read == nil { + return nil + } + select { + case <-read: + return nil + case <-ctx.Done(): + return fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Cancelled) + } +} + +// trusted refuses, with the reason, keys that were never read or are past +// keyStale; v.mu is held. +func (v *Verifier) trusted() error { if v.fetched.IsZero() || v.now().Sub(v.fetched) > keyStale { if errors.Is(v.failure, refusal.InvalidKey) { - return workcontext.Trust{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.InvalidKey) + return fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.InvalidKey) } - return workcontext.Trust{}, errKeysUnavailable + return errKeysUnavailable } - return workcontext.Trust{Issuer: v.trust.Issuer, Keys: v.trust.Keys, Now: v.now}, nil + return nil } // refresh starts the one read, detached from every call; v.mu is held. It @@ -395,11 +538,14 @@ func (v *Verifier) refresh() chan struct{} { go func() { ctx, cancel := context.WithTimeout(context.Background(), v.timeout) defer cancel() - trust, err := v.read(ctx) + trust, admission, err := v.read(ctx) v.mu.Lock() defer v.mu.Unlock() if err == nil { - v.trust, v.fetched = trust, v.now() + v.trust, v.admission, v.fetched = trust, admission, v.now() + if v.replay != nil && len(admission) == 0 { + err = ErrNoAdmissionKeys + } } v.failure, v.inflight = err, nil close(done) @@ -407,28 +553,47 @@ func (v *Verifier) refresh() chan struct{} { return done } -// read asks the authority for its published keys. -func (v *Verifier) read(ctx context.Context) (workcontext.Trust, error) { +// read asks the authority for its published keys: the Work Context keys from +// keys, each used only if its purpose is a Work Context's, and the hop's +// admission keys from admission_keys, each used only if its purpose is +// stamp.Purpose. A key in the wrong field is never used for the other. +func (v *Verifier) read(ctx context.Context) (workcontext.Trust, []string, error) { response, err := v.source.Keys(ctx, &authorityv1.KeysRequest{}) if err != nil { - return workcontext.Trust{}, fmt.Errorf("hop: the authority's published keys cannot be read: %w", err) + return workcontext.Trust{}, nil, fmt.Errorf("hop: the authority's published keys cannot be read: %w", err) } trust := workcontext.Trust{Issuer: response.GetIssuer(), Keys: map[string]ed25519.PublicKey{}} for _, key := range response.GetKeys() { - if key.GetAlgorithm() != core.Algorithm { + if key.GetAlgorithm() != core.Algorithm || (key.GetPurpose() != "" && key.GetPurpose() != workContextPurpose) { continue } if key.GetKeyId() == "" || len(key.GetPublicKey()) != ed25519.PublicKeySize || trust.Keys[key.GetKeyId()] != nil { - return workcontext.Trust{}, refusal.InvalidKey + return workcontext.Trust{}, nil, refusal.InvalidKey } trust.Keys[key.GetKeyId()] = ed25519.PublicKey(slices.Clone(key.GetPublicKey())) } if trust.Issuer == "" || len(trust.Keys) == 0 { - return workcontext.Trust{}, refusal.InvalidKey + return workcontext.Trust{}, nil, refusal.InvalidKey } - return trust, nil + var admission []string + for _, key := range response.GetAdmissionKeys() { + if key.GetAlgorithm() != core.Algorithm || key.GetPurpose() != stamp.Purpose { + continue + } + if len(key.GetPublicKey()) != ed25519.PublicKeySize { + return workcontext.Trust{}, nil, refusal.InvalidKey + } + if public := hex.EncodeToString(key.GetPublicKey()); !slices.Contains(admission, public) { + admission = append(admission, public) + } + } + return trust, admission, nil } +// workContextPurpose is a Work Context key's purpose in Keys. A key published +// with no purpose, by a host older than admission_keys, is one too. +const workContextPurpose = "work-context" + // keyIDOf is the key a token names, read only to choose the keys it is judged // by; "" when it does not decode, which Authenticate then refuses. func keyIDOf(token string) string { @@ -481,6 +646,7 @@ func (v *Verifier) UnaryServerInterceptor() grpc.UnaryServerInterceptor { if err != nil { return nil, err } + defer finishAdmission(ctx) return handler(ctx, req) } } @@ -492,6 +658,7 @@ func (v *Verifier) StreamServerInterceptor() grpc.StreamServerInterceptor { if err != nil { return err } + defer finishAdmission(ctx) return handler(srv, &stamped{ServerStream: ss, ctx: ctx}) } } @@ -500,12 +667,31 @@ func (v *Verifier) admit(ctx context.Context, method string) (context.Context, e if strings.HasPrefix(method, "/grpc.health.v1.Health/") { return ctx, nil } - md, _ := metadata.FromIncomingContext(ctx) - id, err := v.Verify(ctx, md) + held, ok := ctx.Value(admissionKey{}).(*admission) + if !ok || held.verifier != v || held.method != method { + // No stats handler reserved this call: it is reserved now, judged at now. + md, _ := metadata.FromIncomingContext(ctx) + release, err := v.reserve(ctx, md, method, v.instant()) + held = &admission{verifier: v, method: method, md: md, reserved: make(chan struct{}), release: release, err: err} + close(held.reserved) + } + select { + case <-held.reserved: + case <-ctx.Done(): + return nil, status.Error(codes.Unauthenticated, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Cancelled).Error()) + } + if held.err != nil { + return nil, status.Error(codes.Unauthenticated, held.err.Error()) + } + if !held.start() { + return nil, status.Error(codes.Unauthenticated, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Cancelled).Error()) + } + id, err := v.establish(ctx, held.md, held.release) if err != nil { + held.finish() return nil, status.Error(codes.Unauthenticated, err.Error()) } - return context.WithValue(ctx, identityKey{}, id), nil + return context.WithValue(context.WithValue(ctx, admissionKey{}, held), identityKey{}, id), nil } type stamped struct { @@ -521,3 +707,62 @@ func first(md metadata.MD, key string) string { } return "" } + +// StatsHandler reserves each call's single-use admission when the call +// arrives, before its body is read, and the interceptors hold that reservation +// until the handler returns. +// +// grpc-go decodes a unary request fully before it invokes the unary +// interceptor, and the Connect and REST faces buffer the message too. Judging +// the admission there would refuse a genuinely admitted upload that took +// longer than stamp.Lifetime to arrive; consuming its nonce there would let a +// second delivery of the same admission, held back while its body arrived, +// consume a nonce that had since been released, and run the handler twice. +// Installing this handler judges the window at the instant the call reached +// the server and reserves the nonce then, so every delivery that arrives +// inside the window meets the reservation and is refused as stamp_replayed. +// Python's and TypeScript's verifiers run at the same point, before the body +// is read. +// +// It is a stats handler rather than a tap handle because gRPC tags every call +// with it, on every transport the server serves: its own listener, and the +// Connect listener that hands each call to the same server through +// grpc.Server.ServeHTTP, where a tap handle never runs. Install it beside the +// interceptors: +// +// grpc.NewServer(grpc.StatsHandler(verifier.StatsHandler()), +// grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()), +// grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor())) +// +// It does no work on the call's path: it records the arrival instant and +// starts the check and the reservation beside the call, which the interceptor +// awaits and which refuses with the reason. A call that ends before its +// handler starts releases its hold; one whose handler started is released +// when the handler returns. +func (v *Verifier) StatsHandler() stats.Handler { return arrivals{v} } + +// arrivals is the Verifier's stats handler: it tags each call with its +// admission, and observes nothing else. +type arrivals struct{ v *Verifier } + +func (a arrivals) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context { + method := info.FullMethodName + if strings.HasPrefix(method, "/grpc.health.v1.Health/") { + return ctx + } + md, _ := metadata.FromIncomingContext(ctx) + arrival := a.v.instant() + held := &admission{verifier: a.v, method: method, md: md, reserved: make(chan struct{})} + go func() { + defer close(held.reserved) + held.release, held.err = a.v.reserve(ctx, md, method, arrival) + }() + context.AfterFunc(ctx, held.abandon) + return context.WithValue(ctx, admissionKey{}, held) +} + +func (arrivals) HandleRPC(context.Context, stats.RPCStats) {} + +func (arrivals) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context { return ctx } + +func (arrivals) HandleConn(context.Context, stats.ConnStats) {} diff --git a/libraries/hop/go/hop_test.go b/libraries/hop/go/hop_test.go index df4bdc64..b1d04fa4 100644 --- a/libraries/hop/go/hop_test.go +++ b/libraries/hop/go/hop_test.go @@ -27,7 +27,7 @@ var read = &basev0.WorkScopeV1{ResourceKind: "documents", Actions: []string{"rea func verifier(t *testing.T, a *hoptest.Authority, audience string) *hop.Verifier { t.Helper() - v, err := hop.NewVerifier(context.Background(), a, audience, proof) + v, err := hop.NewVerifier(context.Background(), a.PublishHop(proof), audience, hoptest.Replay()) if err != nil { t.Fatal(err) } @@ -37,7 +37,7 @@ func verifier(t *testing.T, a *hoptest.Authority, audience string) *hop.Verifier func TestTheModuleReadsWhoTheCallIsForFromTheAuthenticatedToken(t *testing.T) { a := hoptest.New(t) token := a.Issue(t, "documents", read) - id, err := verifier(t, a, "documents").Verify(context.Background(), hoptest.Forward(t, token, proof)) + id, err := verifier(t, a, "documents").Verify(context.Background(), hoptest.Forward(t, token, proof, "/test.Service/Read"), "/test.Service/Read") if err != nil { t.Fatal(err) } @@ -64,18 +64,21 @@ func TestAModuleHoldingTheProofCannotForgeAContext(t *testing.T) { md metadata.MD reason string }{ - "forged signature": {stampedAs(t, a, forged, genuine), "signature"}, - "unsigned": {stampedAs(t, a, unsigned, genuine), "signature"}, - "another audience": {hoptest.Forward(t, elsewhere, proof), "audience"}, - "no stamp": {metadata.Pairs(hop.WorkContextKey, genuine), "proof"}, - "another run's hop": {hoptest.Forward(t, genuine, "another-run"), "proof"}, - "swapped token": {swapped(hoptest.Forward(t, genuine, proof), a.Issue(t, "documents", read)), "does not match the hop stamp"}, - "stamped tenant lie": {with(hoptest.Forward(t, genuine, proof), hop.TenantKey, "globex"), "stamp_identity"}, - "stamped scope lie": {with(hoptest.Forward(t, genuine, proof), hop.ScopesKey, "documents:read,documents:admin"), "stamp_identity"}, + // A forged token swapped under a genuine admission never reaches the + // signature check: the admission's signed digest binds the token the + // hop stamped, so the swap is refused first. + "forged signature": {stampedAs(t, a, forged, genuine), "stamp_digest"}, + "unsigned": {stampedAs(t, a, unsigned, genuine), "stamp_digest"}, + "another audience": {hoptest.Forward(t, elsewhere, proof, "/test.Service/Read"), "stamp_audience"}, + "no stamp": {metadata.Pairs(hop.WorkContextKey, genuine), "stamp_proof"}, + "another run's hop": {hoptest.Forward(t, genuine, "another-run", "/test.Service/Read"), "stamp_proof"}, + "swapped token": {swapped(hoptest.Forward(t, genuine, proof, "/test.Service/Read"), a.Issue(t, "documents", read)), "stamp_digest"}, + "stamped tenant lie": {with(hoptest.Forward(t, genuine, proof, "/test.Service/Read"), hop.TenantKey, "globex"), "stamp_identity"}, + "stamped scope lie": {with(hoptest.Forward(t, genuine, proof, "/test.Service/Read"), hop.ScopesKey, "documents:read,documents:admin"), "stamp_identity"}, } for name, c := range cases { t.Run(name, func(t *testing.T) { - _, err := v.Verify(context.Background(), c.md) + _, err := v.Verify(context.Background(), c.md, "/test.Service/Read") if !errors.Is(err, workcontext.ErrUnauthenticated) || !strings.Contains(err.Error(), c.reason) { t.Fatalf("want a refusal naming %q, got %v", c.reason, err) } @@ -86,7 +89,7 @@ func TestAModuleHoldingTheProofCannotForgeAContext(t *testing.T) { // stampedAs forwards token under the stamp the hop wrote for genuine, with the // digest rewritten, as any holder of the proof can. func stampedAs(t *testing.T, _ *hoptest.Authority, token, genuine string) metadata.MD { - md := hoptest.Forward(t, genuine, proof) + md := hoptest.Forward(t, genuine, proof, "/test.Service/Read") sum := sha256.Sum256([]byte(token)) md.Set(hop.WorkContextKey, token) md.Set(hop.ContextKey, hex.EncodeToString(sum[:])) @@ -117,7 +120,7 @@ func TestTheInterceptorRefusesByNameAndLetsHealthThrough(t *testing.T) { if _, err := call(metadata.MD{}, "/api.ApiService/WhoAmI"); status.Code(err) != codes.Unauthenticated || !strings.Contains(err.Error(), "proof") { t.Fatalf("no stamp: %v", err) } - if got, err := call(hoptest.Forward(t, a.Issue(t, "console", read), proof), "/api.ApiService/WhoAmI"); err != nil || got != "principal-owner" { + if got, err := call(hoptest.Forward(t, a.Issue(t, "console", read), proof, "/api.ApiService/WhoAmI"), "/api.ApiService/WhoAmI"); err != nil || got != "principal-owner" { t.Fatalf("forwarded: %v %v", got, err) } if _, err := call(metadata.MD{}, "/grpc.health.v1.Health/Check"); err != nil { @@ -130,12 +133,18 @@ func TestTheInterceptorRefusesByNameAndLetsHealthThrough(t *testing.T) { } } -func TestAVerifierNeedsPublishedKeysAnAudienceAndAProof(t *testing.T) { - a := hoptest.New(t) +func TestAVerifierNeedsPublishedKeysAnAudienceAndAReplayStore(t *testing.T) { + a := hoptest.New(t).PublishHop(proof) for name, build := range map[string]func() (*hop.Verifier, error){ - "no keys": func() (*hop.Verifier, error) { return hop.NewVerifier(context.Background(), nil, "documents", proof) }, - "no audience": func() (*hop.Verifier, error) { return hop.NewVerifier(context.Background(), a, "", proof) }, - "no proof": func() (*hop.Verifier, error) { return hop.NewVerifier(context.Background(), a, "documents", "") }, + "no keys": func() (*hop.Verifier, error) { + return hop.NewVerifier(context.Background(), nil, "documents", hoptest.Replay()) + }, + "no audience": func() (*hop.Verifier, error) { + return hop.NewVerifier(context.Background(), a, "", hoptest.Replay()) + }, + "no replay store": func() (*hop.Verifier, error) { + return hop.NewVerifier(context.Background(), a, "documents", nil) + }, } { if _, err := build(); err == nil { t.Fatalf("%s: accepted", name) diff --git a/libraries/hop/go/hoptest/hoptest.go b/libraries/hop/go/hoptest/hoptest.go index 365565d6..e6787dd5 100644 --- a/libraries/hop/go/hoptest/hoptest.go +++ b/libraries/hop/go/hoptest/hoptest.go @@ -1,6 +1,6 @@ // Package hoptest is a real authority for a module's tests: it signs Work -// Contexts with a key of its own, answers Keys for them, and forwards a call -// exactly as the hop stamps it. A module's interceptor tests then exercise the +// Contexts with a key of its own, answers Keys for them and for the hops it +// publishes, and forwards a call exactly as the hop stamps it. A module's interceptor tests then exercise the // same Verifier production runs, with no hand-built header standing in. package hoptest @@ -8,27 +8,36 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "crypto/sha256" + "encoding/hex" "fmt" + "sync" "testing" "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" hop "github.com/codefly-dev/authority/libraries/hop/go" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" core "github.com/codefly-dev/core/workcontext" "google.golang.org/grpc" "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" ) // Issuer is the issuer every Work Context this authority signs names. const Issuer = "https://authority.hoptest" -// Authority signs with a fresh key; Rotate replaces it. +// Authority signs with a fresh key; Rotate replaces it. Keys publishes that +// key and the admission key of every hop PublishHop names. type Authority struct { signer *core.Authority - keys *authorityv1.KeysResponse // Now is the signing clock; nil means time.Now. Now func() time.Time + + mu sync.Mutex + keys *authorityv1.KeysResponse } // New returns an authority with a fresh signing key. @@ -53,22 +62,74 @@ func (a *Authority) Rotate(t testing.TB) { } return time.Now() }} - a.keys = &authorityv1.KeysResponse{Issuer: Issuer, Keys: []*authorityv1.PublicKey{{KeyId: id, Algorithm: core.Algorithm, PublicKey: public}}} + a.mu.Lock() + defer a.mu.Unlock() + var hops []*authorityv1.PublicKey + if a.keys != nil { + hops = a.keys.AdmissionKeys + } + a.keys = &authorityv1.KeysResponse{Issuer: Issuer, Keys: []*authorityv1.PublicKey{{KeyId: id, Algorithm: core.Algorithm, PublicKey: public, Purpose: "work-context"}}, AdmissionKeys: hops} +} + +// PublishHop publishes, beside those already published, the admission key of +// each hop that signs as label (Signer(label)), as an authority publishes its +// hop's key: a Verifier reading this authority's Keys then admits what they +// sign. Publishing two is a rotation's overlap. +func (a *Authority) PublishHop(labels ...string) *Authority { + a.mu.Lock() + defer a.mu.Unlock() + keys := proto.Clone(a.keys).(*authorityv1.KeysResponse) + for _, label := range labels { + keys.AdmissionKeys = append(keys.AdmissionKeys, Published(label)) + } + a.keys = keys + return a +} + +// WithdrawHop stops publishing the admission key of each hop that signs as +// label, as an authority does once a rotation's overlap ends. +func (a *Authority) WithdrawHop(labels ...string) *Authority { + a.mu.Lock() + defer a.mu.Unlock() + keys := proto.Clone(a.keys).(*authorityv1.KeysResponse) + keys.AdmissionKeys = nil + for _, key := range a.keys.AdmissionKeys { + withdrawn := false + for _, label := range labels { + withdrawn = withdrawn || proto.Equal(key, Published(label)) + } + if !withdrawn { + keys.AdmissionKeys = append(keys.AdmissionKeys, key) + } + } + a.keys = keys + return a } // Keys is the authority interface's Keys, as a Verifier reads it. func (a *Authority) Keys(context.Context, *authorityv1.KeysRequest, ...grpc.CallOption) (*authorityv1.KeysResponse, error) { + a.mu.Lock() + defer a.mu.Unlock() return a.keys, nil } // Issue signs a ten-minute Work Context for Core's fixture owner, addressed to // audience, carrying scopes. func (a *Authority) Issue(t testing.TB, audience string, scopes ...*basev0.WorkScopeV1) string { + return a.issue(t, audience, core.ReplayIdempotent, scopes...) +} + +// IssueSingleUse reproduces admission probes with a genuine single-use capability. +func (a *Authority) IssueSingleUse(t testing.TB, audience string, scopes ...*basev0.WorkScopeV1) string { + return a.issue(t, audience, core.ReplaySingleUse, scopes...) +} + +func (a *Authority) issue(t testing.TB, audience, replay string, scopes ...*basev0.WorkScopeV1) string { t.Helper() token, _, err := a.signer.Start(context.Background(), core.StartInput{ TenantID: core.FixtureTenant, OwnerPrincipalID: core.FixturePrincipal, OwnerPrincipalKind: "human", OrganizationID: core.FixtureOrganization, TaskID: core.FixtureTask, Audience: audience, - AuthorityScopes: scopes, InstallationID: core.FixtureInstallation, TTL: 10 * time.Minute, + ReplayPolicy: replay, AuthorityScopes: scopes, InstallationID: core.FixtureInstallation, TTL: 10 * time.Minute, }) if err != nil { t.Fatal(err) @@ -78,13 +139,36 @@ func (a *Authority) Issue(t testing.TB, audience string, scopes ...*basev0.WorkS // Forward is the incoming metadata of a call the hop let through carrying // token: the token, and the stamp the hop writes for it under proof. -func Forward(t testing.TB, token, proof string) metadata.MD { +func Forward(t testing.TB, token, proof, method string) metadata.MD { t.Helper() decoded, err := core.Decode(token) if err != nil { t.Fatal(err) } md := metadata.Pairs(hop.WorkContextKey, token) - hop.Stamp(md, hop.IdentityOf(decoded.Context()), proof) + if err := hop.Stamp(md, hop.IdentityOf(decoded.Context()), Signer(proof), method, decoded.Context().GetAudience(), time.Now()); err != nil { + t.Fatal(err) + } return md } + +// Signer derives a public test key. Production seeds come only from Codefly. +// An Authority publishes its admission key once PublishHop names label. +func Signer(label string) *stamp.Signer { + sum := sha256.Sum256([]byte(label)) + signer, _ := stamp.NewSigner(hex.EncodeToString(sum[:])) + return signer +} +func PublicKey(label string) string { return Signer(label).PublicKey() } + +// Published is the admission key of the hop that signs as label, as an +// authority's Keys carries it in admission_keys. +func Published(label string) *authorityv1.PublicKey { + id, public := Signer(label).Admission() + return &authorityv1.PublicKey{KeyId: id, Algorithm: core.Algorithm, PublicKey: public, Purpose: stamp.Purpose} +} + +// Replay is for single-process tests only; deployments supply a shared store. +// It declares no Shared(), so hop.NewVerifier refuses it outside a local run +// rather than leaving that to this comment. +func Replay() core.ReplayStore { return core.NewMemoryReplayStore() } diff --git a/libraries/hop/go/main_test.go b/libraries/hop/go/main_test.go new file mode 100644 index 00000000..1eb02e24 --- /dev/null +++ b/libraries/hop/go/main_test.go @@ -0,0 +1,14 @@ +package hop_test + +import ( + "os" + "testing" +) + +// TestMain runs this package's tests in the local run: most stand up a verifier +// on a process-local store, which only the run named exactly "local" may use. +// A test of the startup guard or over Postgres sets its own environment. +func TestMain(m *testing.M) { + _ = os.Setenv("CODEFLY__ENVIRONMENT", "local") + os.Exit(m.Run()) +} diff --git a/libraries/hop/go/owner_test.go b/libraries/hop/go/owner_test.go index f047ef38..35ba36cd 100644 --- a/libraries/hop/go/owner_test.go +++ b/libraries/hop/go/owner_test.go @@ -21,7 +21,7 @@ func ownedScope(t *testing.T, kind, action, owner string, ids ...string) *basev0 func TestAnOwnerSelectedScopeNeverReadsAsTheWholeKind(t *testing.T) { a := hoptest.New(t) token := a.Issue(t, "documents", ownedScope(t, "documents", "read", "principal-u")) - id, err := verifier(t, a, "documents").Verify(context.Background(), hoptest.Forward(t, token, proof)) + id, err := verifier(t, a, "documents").Verify(context.Background(), hoptest.Forward(t, token, proof, "/test.Service/Read"), "/test.Service/Read") if err != nil { t.Fatalf("a capability with an owner-selected scope must verify: %v", err) } diff --git a/libraries/hop/go/published_test.go b/libraries/hop/go/published_test.go new file mode 100644 index 00000000..8f82ea98 --- /dev/null +++ b/libraries/hop/go/published_test.go @@ -0,0 +1,171 @@ +package hop + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "errors" + "testing" + "time" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + core "github.com/codefly-dev/core/workcontext" +) + +// Review r3 B1: the hop's admission key is published by the authority's Keys, +// which every verifier already reads, so nothing about the hop is configured +// on a module: a verifier built from Keys alone admits a genuine stamp. +func TestAVerifierTakesTheHopsKeyFromTheAuthoritysKeysAlone(t *testing.T) { + authority := newSigner("published") + source := &keys{response: &authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}, + AdmissionKeys: []*authorityv1.PublicKey{publishedHop("the-hop")}}} + v, err := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + if err != nil { + t.Fatal(err) + } + id, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, time.Now(), "documents"), "the-hop", ""), "/test.Service/Read") + if err != nil || id.Owner != core.FixturePrincipal { + t.Fatalf("a genuine stamp under the published key: %+v %v", id, err) + } +} + +// A stamp signed by a key the authority does not publish is refused as +// stamp_proof, and causes no read of the authority's keys: a caller cannot +// make the verifier re-read by presenting an unknown key. +func TestAStampSignedByAnUnpublishedKeyIsRefusedByName(t *testing.T) { + authority := newSigner("published") + source := &keys{response: &authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}}} + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + settled(v) + reads := source.count() + _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, time.Now(), "documents"), "an-unpublished-hop", ""), "/test.Service/Read") + var reason refusal.Reason + if !errors.As(err, &reason) || reason != refusal.StampProof { + t.Fatalf("want %s: %v", refusal.StampProof, err) + } + settled(v) + if source.count() != reads { + t.Fatalf("an unpublished key caused %d key reads", source.count()-reads) + } +} + +// While a hop's key rotates the authority publishes both, and the verifier +// admits stamps under either; once the retiring key is withdrawn, the next +// read stops admitting it. +func TestARotationsOverlapAdmitsBothKeysUntilTheRetiringOneIsWithdrawn(t *testing.T) { + authority := newSigner("published") + both := &authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}, + AdmissionKeys: []*authorityv1.PublicKey{publishedHop("retiring"), publishedHop("current")}} + source := &keys{response: both} + clock := time.Now() + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + v.now = func() time.Time { return clock } + v.fetched, v.tried = clock, clock + for _, hop := range []string{"retiring", "current"} { + if _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, clock, "documents"), hop, ""), "/test.Service/Read"); err != nil { + t.Fatalf("the overlap refused the %s key: %v", hop, err) + } + } + source.set(&authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}, + AdmissionKeys: []*authorityv1.PublicKey{publishedHop("current")}}, nil, nil) + clock = clock.Add(keyMaxAge + time.Second) + v.Ready() // the periodic re-read + settled(v) + _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, clock, "documents"), "retiring", ""), "/test.Service/Read") + var reason refusal.Reason + if !errors.As(err, &reason) || reason != refusal.StampProof { + t.Fatalf("a withdrawn key still admits: %v", err) + } + if _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, clock, "documents"), "current", ""), "/test.Service/Read"); err != nil { + t.Fatalf("the current key after the overlap: %v", err) + } +} + +// Each key is used for its own purpose only: an admission key published among +// the Work Context keys admits nothing, and an admission key never +// authenticates a Work Context. +func TestAKeyIsUsedForItsOwnPurposeOnly(t *testing.T) { + authority := newSigner("published") + misplaced := publishedHop("misplaced") + source := &keys{response: &authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published(), misplaced}}} + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + var reason refusal.Reason + if _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, time.Now(), "documents"), "misplaced", ""), "/test.Service/Read"); !errors.As(err, &reason) || reason != refusal.StampProof { + t.Fatalf("an admission key among the Work Context keys admitted: %v", err) + } + // A Work Context signed with the hop's admission key (the keys double + // publishes testSigner("proof")), under that entry's key id. + seed := sha256.Sum256([]byte("proof")) + forged := signer{id: publishedHop("proof").GetKeyId(), key: ed25519.NewKeyFromSeed(seed[:]), issuer: authority.issuer} + if _, err := v.VerifyToken(context.Background(), forged.issue(t, time.Now(), "documents")); !errors.As(err, &reason) || reason != refusal.UnknownKey { + t.Fatalf("a Work Context signed with the hop's admission key: %v", err) + } +} + +// An authority that publishes no admission key admits nothing, under a fixed +// reason, and says why in the module's logs. +func TestNoPublishedAdmissionKeyRefusesEveryCallAndSaysWhy(t *testing.T) { + authority := newSigner("published") + source := &keys{response: &authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}, + AdmissionKeys: []*authorityv1.PublicKey{{Algorithm: core.Algorithm, PublicKey: make([]byte, 32), Purpose: "work-context"}}}} + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, time.Now(), "documents"), "proof", ""), "/test.Service/Read") + var reason refusal.Reason + if !errors.As(err, &reason) || reason != refusal.KeysUnavailable { + t.Fatalf("want %s: %v", refusal.KeysUnavailable, err) + } + if !errors.Is(v.KeysError(), ErrNoAdmissionKeys) { + t.Fatalf("the module's logs are not told why: %v", v.KeysError()) + } +} + +// Review r5: a verifier that first read Keys before the hop registered its +// key holds no admission key; once the authority publishes one, the next call +// or readiness check after keyRetry reads it, so the module admits a genuine +// stamp without waiting out keyMaxAge. Until then every call is refused as +// keys_unavailable, and an unknown caller drives at most one read per +// keyRetry. +func TestAVerifierThatReadBeforeTheHopRegisteredLearnsItsKeyWithinKeyRetry(t *testing.T) { + authority := newSigner("published") + // No admission key published (the double publishes one when the field is + // empty, so it carries only an entry of another purpose, which is dropped). + unregistered := func() *authorityv1.KeysResponse { + return &authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}, + AdmissionKeys: []*authorityv1.PublicKey{{Algorithm: core.Algorithm, PublicKey: make([]byte, 32), Purpose: "work-context"}}} + } + source := &keys{response: unregistered()} + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + settled(v) + clock := time.Now() + v.now = func() time.Time { return clock } + var reason refusal.Reason + if _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, clock, "documents"), "the-hop", ""), "/test.Service/Read"); !errors.As(err, &reason) || reason != refusal.KeysUnavailable { + t.Fatalf("before the hop registered: %v", err) + } + reads := source.count() + for range 5 { + _, _ = v.Verify(context.Background(), forwarded(t, authority.issue(t, clock, "documents"), "the-hop", ""), "/test.Service/Read") + } + settled(v) + if source.count() != reads { + t.Fatalf("callers inside keyRetry drove %d key reads", source.count()-reads) + } + source.set(&authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}, + AdmissionKeys: []*authorityv1.PublicKey{publishedHop("the-hop")}}, nil, nil) + clock = clock.Add(keyRetry) + if id, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, clock, "documents"), "the-hop", ""), "/test.Service/Read"); err != nil || id.Owner != core.FixturePrincipal { + t.Fatalf("after the hop registered: %+v %v", id, err) + } + // Readiness reads too, for a replica that gets no call meanwhile. + idle, _ := NewVerifier(context.Background(), &keys{response: unregistered()}, "documents", core.NewMemoryReplayStore()) + idle.now = func() time.Time { return clock } + settled(idle) + idle.source.(*keys).set(source.response, nil, nil) + clock = clock.Add(keyRetry) + idle.Ready() + settled(idle) + if idle.KeysError() != nil { + t.Fatalf("readiness did not learn the registered key: %v", idle.KeysError()) + } +} diff --git a/libraries/hop/go/reasons_test.go b/libraries/hop/go/reasons_test.go index 6da86b81..62aac9c1 100644 --- a/libraries/hop/go/reasons_test.go +++ b/libraries/hop/go/reasons_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + core "github.com/codefly-dev/core/workcontext" "google.golang.org/grpc/metadata" "strings" "testing" @@ -13,7 +14,7 @@ import ( func TestStampFailureCodesAreDistinctAndSafe(t *testing.T) { s := newSigner("reasons") source := &keys{response: &authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}}} - v, err := NewVerifier(t.Context(), source, "module", "proof") + v, err := NewVerifier(t.Context(), source, "module", core.NewMemoryReplayStore()) if err != nil { t.Fatal(err) } @@ -31,7 +32,7 @@ func TestStampFailureCodesAreDistinctAndSafe(t *testing.T) { for _, tc := range cases { md := forwarded(t, token, "proof", "") tc.change(md) - _, err := v.Verify(t.Context(), md) + _, err := v.Verify(t.Context(), md, "/test.Service/Read") var reason Reason if !errors.As(err, &reason) || reason != tc.want { t.Fatalf("want %s: %v", tc.want, err) @@ -44,14 +45,14 @@ func TestStampFailureCodesAreDistinctAndSafe(t *testing.T) { } func TestConfigurationAndCancelledKeyWaitHaveNamedReasons(t *testing.T) { - _, err := NewVerifier(t.Context(), nil, "module", "proof") + _, err := NewVerifier(t.Context(), nil, "module", core.NewMemoryReplayStore()) var reason Reason if !errors.As(err, &reason) || reason != ReasonConfiguration { t.Fatalf("configuration: %v", err) } s := newSigner("cancel-reason") source := &keys{response: &authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}}} - v, err := NewVerifier(t.Context(), source, "module", "proof") + v, err := NewVerifier(t.Context(), source, "module", core.NewMemoryReplayStore()) if err != nil { t.Fatal(err) } diff --git a/libraries/hop/go/register.go b/libraries/hop/go/register.go new file mode 100644 index 00000000..cf805278 --- /dev/null +++ b/libraries/hop/go/register.go @@ -0,0 +1,102 @@ +package hop + +import ( + "context" + "sync" + "time" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "google.golang.org/grpc" +) + +// Registrar is the authority interface's RegisterAdmissionKey, as its +// generated client serves it. +type Registrar interface { + RegisterAdmissionKey(ctx context.Context, in *authorityv1.RegisterAdmissionKeyRequest, opts ...grpc.CallOption) (*authorityv1.RegisterAdmissionKeyResponse, error) +} + +// registerRetry is how soon a registration that failed is tried again. +const registerRetry = 5 * time.Second + +// Registration keeps the hop's admission key registered with the authority, +// which publishes it in Keys for every module behind the hop: the hop presents +// the public half with its workload credential, never its seed. A registration +// lapses unless renewed, so the hop renews it while it runs, and a restarted +// authority learns the key again at the next renewal. +type Registration struct { + Authority Registrar + Signer *stamp.Signer + // Credential returns the hop's workload credential, read fresh on every + // registration so a rotation takes effect (RunCredential). + Credential func() (string, error) + // Report is told each time the outcome changes: the published key and + // when it lapses, or why the authority did not publish it. + Report func(key *authorityv1.PublicKey, expires time.Time, err error) +} + +// Register registers the key once and answers the published entry and when +// it lapses unless registered again. +func (r Registration) Register(ctx context.Context) (*authorityv1.PublicKey, time.Time, error) { + credential, err := r.Credential() + if err != nil { + return nil, time.Time{}, err + } + _, public := r.Signer.Admission() + out, err := r.Authority.RegisterAdmissionKey(ctx, &authorityv1.RegisterAdmissionKeyRequest{ + Actor: &authorityv1.WorkloadCredential{Credential: credential}, PublicKey: public, + }) + if err != nil { + return nil, time.Time{}, err + } + return out.GetKey(), time.Unix(out.GetExpiresAtUnix(), 0), nil +} + +// Keep registers the key now, then renews it at a quarter of each lease's +// remaining time, or every registerRetry after a failure, until stop is +// called. The first outcome is reported before Keep returns, so the hop's +// boot says whether its key is published; a failure does not stop renewal, +// since the authority may answer later. +func (r Registration) Keep(ctx context.Context) (stop func()) { + ctx, cancel := context.WithCancel(ctx) + var last string + attempt := func() time.Duration { + call, done := context.WithTimeout(ctx, registerRetry) + defer done() + key, expires, err := r.Register(call) + outcome := "ok " + key.GetKeyId() + if err != nil { + outcome = err.Error() + } + if outcome != last && ctx.Err() == nil { + last = outcome + if r.Report != nil { + r.Report(key, expires, err) + } + } + if err != nil { + return registerRetry + } + return max(time.Until(expires)/4, time.Second) + } + next := attempt() + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + timer := time.NewTimer(next) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + timer.Reset(attempt()) + } + } + }() + return func() { + cancel() + wg.Wait() + } +} diff --git a/libraries/hop/go/register_test.go b/libraries/hop/go/register_test.go new file mode 100644 index 00000000..1dfd824e --- /dev/null +++ b/libraries/hop/go/register_test.go @@ -0,0 +1,115 @@ +package hop + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "google.golang.org/grpc" +) + +// registrar is an authority answering RegisterAdmissionKey with a lease, or +// refusing while refuse is set; it records what it was presented. +type registrar struct { + mu sync.Mutex + lease time.Duration + refuse error + credentials []string + keys [][]byte +} + +func (r *registrar) RegisterAdmissionKey(_ context.Context, in *authorityv1.RegisterAdmissionKeyRequest, _ ...grpc.CallOption) (*authorityv1.RegisterAdmissionKeyResponse, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.credentials = append(r.credentials, in.GetActor().GetCredential()) + r.keys = append(r.keys, in.GetPublicKey()) + if r.refuse != nil { + return nil, r.refuse + } + return &authorityv1.RegisterAdmissionKeyResponse{ + Key: &authorityv1.PublicKey{KeyId: stamp.KeyID(in.GetPublicKey()), PublicKey: in.GetPublicKey(), Purpose: stamp.Purpose}, + ExpiresAtUnix: time.Now().Add(r.lease).Unix(), + }, nil +} + +func (r *registrar) calls() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.keys) +} + +// The hop presents its public key and its workload credential, read fresh on +// every registration, never its seed; the first outcome is reported before +// Keep returns, and the registration is renewed while the hop runs. +func TestTheHopKeepsItsPublicKeyRegisteredUnderItsCredential(t *testing.T) { + authority := ®istrar{lease: 4 * time.Second} + signer := testSigner("the-hop") + rotation := 0 + var reports []string + var mu sync.Mutex + stop := Registration{Authority: authority, Signer: signer, + Credential: func() (string, error) { rotation++; return "credential-" + string(rune('0'+rotation)), nil }, + Report: func(key *authorityv1.PublicKey, _ time.Time, err error) { + mu.Lock() + defer mu.Unlock() + if err != nil { + reports = append(reports, err.Error()) + return + } + reports = append(reports, key.GetKeyId()) + }}.Keep(context.Background()) + mu.Lock() + id, public := signer.Admission() + if len(reports) != 1 || reports[0] != id { + t.Fatalf("Keep returned before reporting the first registration: %v", reports) + } + mu.Unlock() + deadline := time.Now().Add(5 * time.Second) + for authority.calls() < 2 && time.Now().Before(deadline) { + time.Sleep(50 * time.Millisecond) + } + stop() + authority.mu.Lock() + defer authority.mu.Unlock() + if len(authority.keys) < 2 { + t.Fatalf("the registration was not renewed inside its lease: %d registrations", len(authority.keys)) + } + for n, key := range authority.keys { + if string(key) != string(public) { + t.Fatalf("registration %d presented %x, not the hop's public key", n, key) + } + } + if authority.credentials[0] == authority.credentials[1] { + t.Fatal("the credential was not read fresh for the renewal") + } + mu.Lock() + defer mu.Unlock() + if len(reports) != 1 { + t.Fatalf("an unchanged outcome was reported again: %v", reports) + } +} + +// An authority that refuses (or is not up yet) is reported once and retried; +// a hop with no credential presents nothing. +func TestARefusedRegistrationIsReportedAndRetried(t *testing.T) { + refused := errors.New("rpc error: code = PermissionDenied desc = the caller is console/api, not the hop") + authority := ®istrar{lease: time.Minute, refuse: refused} + var got error + stop := Registration{Authority: authority, Signer: testSigner("the-hop"), + Credential: func() (string, error) { return "credential", nil }, + Report: func(_ *authorityv1.PublicKey, _ time.Time, err error) { got = err }}.Keep(context.Background()) + stop() + if !errors.Is(got, refused) { + t.Fatalf("the refusal was not reported: %v", got) + } + none := ®istrar{lease: time.Minute} + _, _, err := Registration{Authority: none, Signer: testSigner("the-hop"), + Credential: func() (string, error) { return "", errors.New("CODEFLY__WORKLOAD_TOKEN_FILE is not set") }}.Register(context.Background()) + if err == nil || none.calls() != 0 { + t.Fatalf("a hop with no credential registered: %v (%d calls)", err, none.calls()) + } +} diff --git a/libraries/hop/go/replay.go b/libraries/hop/go/replay.go new file mode 100644 index 00000000..43f0edad --- /dev/null +++ b/libraries/hop/go/replay.go @@ -0,0 +1,92 @@ +package hop + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + core "github.com/codefly-dev/core/workcontext" +) + +// FileReplayStore implements Core's ReplayStore on a durable shared filesystem. +// All replicas of an audience must mount the SAME directory, isolated from +// other workloads, with atomic exclusive creation and +// durable fsync semantics. A local ephemeral volume is not a shared store. +// Reservations deliberately survive expiry and restart. Operators may remove +// files only after their recorded expiry, with all verifiers' clocks past it. +// An incomplete file is retained: an interrupted reservation fails closed. +type FileReplayStore struct { + directory string + shared bool +} + +// NewFileReplayStore opens a store on directory. Nothing here can tell whether +// that directory is genuinely shared by every replica: an absolute path to +// per-pod storage (/tmp, an emptyDir) looks exactly the same, and a store that +// is per-pod lets a captured admission replay once per replica. So this store +// does not declare itself shared, and NewVerifier refuses it outside a local +// run. An operator who knows the mount is shared says so with +// NewSharedFileReplayStore. +func NewFileReplayStore(directory string) (*FileReplayStore, error) { + return openFileReplayStore(directory, false) +} + +// NewSharedFileReplayStore is NewFileReplayStore for a directory the caller +// states is one durable mount every replica of this audience shares, with +// atomic exclusive creation and durable fsync semantics. Saying so is an +// assertion about the deployment that this library cannot check; a module with +// a store should prefer replaypg, where the guarantee is the database's. +func NewSharedFileReplayStore(directory string) (*FileReplayStore, error) { + return openFileReplayStore(directory, true) +} + +// Shared reports what the constructor was told. NewVerifier reads it. +func (s *FileReplayStore) Shared() bool { return s.shared } + +func openFileReplayStore(directory string, shared bool) (*FileReplayStore, error) { + if !filepath.IsAbs(directory) { + return nil, errors.New("hop: replay directory must be an absolute delivered shared volume path") + } + info, err := os.Stat(directory) + if err != nil || !info.IsDir() { + return nil, errors.New("hop: delivered replay directory is unavailable") + } + return &FileReplayStore{directory: directory, shared: shared}, nil +} + +func (s *FileReplayStore) Consume(ctx context.Context, nonce string, retainUntil time.Time) error { + if err := ctx.Err(); err != nil { + return err + } + sum := sha256.Sum256([]byte(nonce)) + file, err := os.OpenFile(filepath.Join(s.directory, hex.EncodeToString(sum[:])), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if errors.Is(err, os.ErrExist) { + return core.ErrReplayed + } + if err != nil { + return errors.New("hop: replay store reservation unavailable") + } + // Never remove a reservation on error: another process may already have + // observed it, and a failed fsync cannot safely establish whether it persisted. + _, writeErr := fmt.Fprintf(file, "%d\n", retainUntil.Unix()) + syncErr := file.Sync() + closeErr := file.Close() + if writeErr != nil || syncErr != nil || closeErr != nil { + return errors.New("hop: replay reservation could not be persisted") + } + directory, err := os.Open(s.directory) + if err != nil { + return errors.New("hop: replay directory unavailable") + } + syncErr = directory.Sync() + closeErr = directory.Close() + if syncErr != nil || closeErr != nil { + return errors.New("hop: replay directory could not be persisted") + } + return nil +} diff --git a/libraries/hop/go/replay_test.go b/libraries/hop/go/replay_test.go new file mode 100644 index 00000000..6d59c1b6 --- /dev/null +++ b/libraries/hop/go/replay_test.go @@ -0,0 +1,42 @@ +package hop_test + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + hop "github.com/codefly-dev/authority/libraries/hop/go" +) + +func TestReplayReservationIsAtomicAcrossInstancesAndSurvivesRestart(t *testing.T) { + directory := t.TempDir() + var accepted atomic.Int64 + var wg sync.WaitGroup + for n := 0; n < 32; n++ { + wg.Add(1) + go func() { + defer wg.Done() + store, err := hop.NewFileReplayStore(directory) + if err != nil { + t.Error(err) + return + } + if store.Consume(context.Background(), "same-call", time.Now().Add(time.Minute)) == nil { + accepted.Add(1) + } + }() + } + wg.Wait() + if accepted.Load() != 1 { + t.Fatalf("accepted %d calls, want one", accepted.Load()) + } + restarted, err := hop.NewFileReplayStore(directory) + if err != nil { + t.Fatal(err) + } + if restarted.Consume(context.Background(), "same-call", time.Now().Add(time.Minute)) == nil { + t.Fatal("replayed after restart") + } +} diff --git a/libraries/hop/go/replaypg/replaypg.go b/libraries/hop/go/replaypg/replaypg.go new file mode 100644 index 00000000..0ab05495 --- /dev/null +++ b/libraries/hop/go/replaypg/replaypg.go @@ -0,0 +1,154 @@ +// Package replaypg is the hop admission replay store on Postgres: the durable, +// atomic, shared reservation the admission contract requires, over a store +// Codefly already delivers to a module. +// +// It exists because the contract's requirement had no deployable backend. A +// process-local set fails on a second replica and after a restart, and +// hop.FileReplayStore needs a shared mount the platform does not deliver, so a +// hand-written path to per-pod storage would silently become a per-replica +// store and let a captured stamp replay once per replica. A module with a store +// has somewhere atomic to put a nonce, and INSERT ... ON CONFLICT DO NOTHING +// over a primary key is that atomic reservation, shared by every replica +// reaching the same database. +// +// Create the table with Migration, as the module's own migration, and keep +// Collect on a schedule: reservations are retained past their admission's +// expiry (hop passes expiry plus the clock allowance) and nothing removes them +// otherwise. +package replaypg + +import ( + "context" + "errors" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + hop "github.com/codefly-dev/authority/libraries/hop/go" + core "github.com/codefly-dev/core/workcontext" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// Migration creates the table, as the module's own migration. The primary key +// is the reservation: Postgres decides one winner among concurrent inserts. +// active marks a reservation whose call is still running, for diagnosis: +// collection does not read it. +const Migration = `CREATE TABLE IF NOT EXISTS hop_admissions ( + nonce TEXT PRIMARY KEY, + retain_until TIMESTAMPTZ NOT NULL, + active BOOLEAN NOT NULL DEFAULT FALSE +); +CREATE INDEX IF NOT EXISTS hop_admissions_retain_until ON hop_admissions (retain_until);` + +// Pool is the part of *pgxpool.Pool this store uses, so a caller may wrap it +// and a test may stand in for it. +type Pool interface { + Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) + QueryRow(ctx context.Context, sql string, arguments ...any) pgx.Row +} + +// Store reserves admission nonces in a module's own Postgres. +type Store struct{ pool Pool } + +// New wraps a pool connected as the role that may insert into hop_admissions. +func New(pool Pool) (*Store, error) { + if pool == nil { + return nil, errors.New("hop replay: a Postgres replay store needs a connection pool") + } + return &Store{pool: pool}, nil +} + +var _ core.ReplayStore = (*Store)(nil) + +// Shared marks this store as the durable shared reservation the contract +// requires, so hop.NewVerifier admits it outside local. +func (s *Store) Shared() bool { return true } + +// Consume reserves nonce until retainUntil, atomically across every replica +// reaching this database. It reports core.ErrReplayed when the nonce is already +// held, and any other error as unavailability, which the verifier refuses: a +// store that cannot answer must never admit a call. +// +// A reservation is never updated. Letting a second insert refresh retain_until +// would let a replay extend the window it is being refused for. +func (s *Store) Consume(ctx context.Context, nonce string, retainUntil time.Time) error { + if nonce == "" { + return errors.New("hop replay: a reservation needs a nonce") + } + tag, err := s.pool.Exec(ctx, `INSERT INTO hop_admissions (nonce, retain_until) + VALUES ($1, $2) ON CONFLICT (nonce) DO NOTHING`, nonce, retainUntil.UTC()) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return core.ErrReplayed + } + return nil +} + +// CollectMargin is how long past its retention a reservation is kept before +// Collect removes it. Reserve's check that the retention has not passed and +// its insert are one statement but not one instant: a Collect that removed a +// row the moment its retention passed could commit between them and let the +// insert revive the nonce. Collecting only rows a whole margin past retention +// leaves no such instant. +const CollectMargin = time.Minute + +// Collect removes reservations more than CollectMargin past their retention, +// by the database's own clock, and answers how many it removed. Run it on a +// schedule: without it the table grows by one row per admitted call. +// +// A reservation is removed then whether or not its call has finished: Reserve +// refuses, on the same clock, any reservation past that retention, and a +// delivery past it is refused on the window too, so removing it can never +// revive the nonce. A holder that stopped between Reserve and Finish (a crash, +// a failed or timed-out Finish, a Reserve that committed after its caller gave +// up) therefore leaves no permanent row. +func (s *Store) Collect(ctx context.Context) (int64, error) { + tag, err := s.pool.Exec(ctx, `DELETE FROM hop_admissions WHERE retain_until < now() - make_interval(secs => $1)`, CollectMargin.Seconds()) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +var _ hop.ReservationStore = (*Store)(nil) + +// Reserve consumes nonce when a call arrives and holds it, marked active until +// Finish, to retainUntil. It reports core.ErrReplayed +// when the nonce is already held, and refusal.StampWindow when retainUntil has +// already passed by the database's clock: a reservation delayed past its own +// horizon must never revive a nonce that Collect removed. +func (s *Store) Reserve(ctx context.Context, nonce string, retainUntil time.Time) error { + if nonce == "" { + return errors.New("hop replay: a reservation needs a nonce") + } + var outcome string + err := s.pool.QueryRow(ctx, `WITH reserved AS ( + INSERT INTO hop_admissions (nonce, retain_until, active) + SELECT $1, $2::timestamptz, TRUE WHERE $2::timestamptz > clock_timestamp() + ON CONFLICT (nonce) DO NOTHING + RETURNING nonce) + SELECT CASE WHEN EXISTS (SELECT 1 FROM reserved) THEN 'reserved' + WHEN $2::timestamptz > clock_timestamp() THEN 'replayed' + ELSE 'expired' END`, nonce, retainUntil.UTC()).Scan(&outcome) + if err != nil { + return err + } + switch outcome { + case "reserved": + return nil + case "replayed": + return core.ErrReplayed + default: + return refusal.StampWindow + } +} + +// Finish ends the in-flight hold of a call that has returned. The reservation +// itself stays until its retention passes. A Finish that fails, or a process +// that stops before it, leaves the hold in place: nothing reopens admission. +func (s *Store) Finish(ctx context.Context, nonce string) error { + _, err := s.pool.Exec(ctx, `UPDATE hop_admissions SET active = FALSE WHERE nonce = $1`, nonce) + return err +} diff --git a/libraries/hop/go/replaypg/replaypg_test.go b/libraries/hop/go/replaypg/replaypg_test.go new file mode 100644 index 00000000..4864f857 --- /dev/null +++ b/libraries/hop/go/replaypg/replaypg_test.go @@ -0,0 +1,348 @@ +package replaypg + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + core "github.com/codefly-dev/core/workcontext" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// pool is a Postgres that holds one nonce per primary key, as the real table's +// ON CONFLICT DO NOTHING does: the first insert affects a row, a repeat none. +type pool struct { + held map[string]time.Time + active map[string]bool + fail error + sql []string + args [][]any + // ahead is how far the database's clock runs ahead of this process's. + ahead time.Duration +} + +func (p *pool) now() time.Time { return time.Now().Add(p.ahead) } + +func (p *pool) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) { + p.sql, p.args = append(p.sql, sql), append(p.args, args) + if p.fail != nil { + return pgconn.CommandTag{}, p.fail + } + if p.held == nil { + p.held = map[string]time.Time{} + } + switch strings.Fields(sql)[0] { + case "UPDATE": + // Finish: the call has returned. + delete(p.active, args[0].(string)) + return pgconn.NewCommandTag("UPDATE 1"), nil + case "DELETE": + // The collection statement; remove what is a margin past its + // retention, running or not. + margin := time.Duration(args[0].(float64) * float64(time.Second)) + removed := 0 + for nonce, until := range p.held { + if until.Before(p.now().Add(-margin)) { + delete(p.held, nonce) + removed++ + } + } + return pgconn.NewCommandTag("DELETE 0 " + itoa(removed)), nil + } + nonce, until := args[0].(string), args[1].(time.Time) + if _, replayed := p.held[nonce]; replayed { + return pgconn.NewCommandTag("INSERT 0 0"), nil + } + p.held[nonce] = until + return pgconn.NewCommandTag("INSERT 0 1"), nil +} + +// QueryRow is the reservation statement: the insert happens only while the +// retention has not passed, and the answer says which way it went. +func (p *pool) QueryRow(_ context.Context, sql string, args ...any) pgx.Row { + p.sql, p.args = append(p.sql, sql), append(p.args, args) + if p.fail != nil { + return row{err: p.fail} + } + if p.held == nil { + p.held, p.active = map[string]time.Time{}, map[string]bool{} + } + nonce, until := args[0].(string), args[1].(time.Time) + _, taken := p.held[nonce] + switch { + case !until.After(p.now()): + return row{outcome: "expired"} + case taken: + return row{outcome: "replayed"} + } + p.held[nonce], p.active[nonce] = until, true + return row{outcome: "reserved"} +} + +type row struct { + outcome string + err error +} + +func (r row) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + *dest[0].(*string) = r.outcome + return nil +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var out []byte + for ; n > 0; n /= 10 { + out = append([]byte{byte('0' + n%10)}, out...) + } + return string(out) +} + +func TestAReservationIsHeldOnceAndAReplayIsRefused(t *testing.T) { + p := &pool{} + store, err := New(p) + if err != nil { + t.Fatal(err) + } + if !store.Shared() { + t.Fatal("a Postgres store is the shared reservation the contract requires") + } + until := time.Now().Add(32 * time.Second) + if err := store.Consume(context.Background(), "nonce", until); err != nil { + t.Fatal(err) + } + if err := store.Consume(context.Background(), "nonce", until); !errors.Is(err, core.ErrReplayed) { + t.Fatalf("a replay was not refused: %v", err) + } + // A different nonce is unaffected, and retention is stored in UTC. + if err := store.Consume(context.Background(), "other", until); err != nil { + t.Fatal(err) + } + if stored := p.args[0][1].(time.Time); stored.Location() != time.UTC || !stored.Equal(until) { + t.Fatalf("retention stored as %v", stored) + } + // A reservation is never refreshed: a replay cannot extend its own window. + if err := store.Consume(context.Background(), "nonce", until.Add(time.Hour)); !errors.Is(err, core.ErrReplayed) { + t.Fatalf("a replay was not refused: %v", err) + } + if held := p.held["nonce"]; !held.Equal(until) { + t.Fatalf("a refused replay moved the retention to %v", held) + } +} + +// A store that cannot answer must never admit a call: the error is neither +// swallowed nor reported as a replay, so hop.Verify refuses it as stamp_store. +func TestAnUnavailableStoreIsNeitherAdmissionNorReplay(t *testing.T) { + outage := errors.New("connection refused") + store, err := New(&pool{fail: outage}) + if err != nil { + t.Fatal(err) + } + err = store.Consume(context.Background(), "nonce", time.Now().Add(time.Minute)) + if !errors.Is(err, outage) || errors.Is(err, core.ErrReplayed) { + t.Fatalf("an outage must not read as a replay: %v", err) + } + if _, err := New(nil); err == nil { + t.Fatal("a store with no pool was accepted") + } + if err := store.Consume(context.Background(), "", time.Now()); err == nil { + t.Fatal("a reservation with no nonce was accepted") + } +} + +func TestCollectRemovesOnlyReservationsPastTheirRetention(t *testing.T) { + p := &pool{} + store, _ := New(p) + if err := store.Consume(context.Background(), "aged", time.Now().Add(-CollectMargin-time.Second)); err != nil { + t.Fatal(err) + } + if err := store.Consume(context.Background(), "inside-the-margin", time.Now().Add(-time.Second)); err != nil { + t.Fatal(err) + } + if err := store.Consume(context.Background(), "current", time.Now().Add(time.Minute)); err != nil { + t.Fatal(err) + } + removed, err := store.Collect(context.Background()) + if err != nil || removed != 1 { + t.Fatalf("collected %d: %v", removed, err) + } + for _, kept := range []string{"current", "inside-the-margin"} { + if _, held := p.held[kept]; !held { + t.Fatalf("collection removed %s", kept) + } + } +} + +// A reservation is refused to anyone else while it is retained; collection +// removes it once its retention has passed, its call finished or not, and a +// reservation whose retention has already passed is refused as the window, +// never inserted, so it cannot revive a collected nonce. +func TestAReservationIsHeldToItsRetentionWhetherOrNotItsCallFinished(t *testing.T) { + p := &pool{} + store, _ := New(p) + ctx := context.Background() + if err := store.Reserve(ctx, "running", time.Now().Add(50*time.Millisecond)); err != nil { + t.Fatal(err) + } + if err := store.Reserve(ctx, "running", time.Now().Add(time.Minute)); !errors.Is(err, core.ErrReplayed) { + t.Fatalf("a second reservation was not refused: %v", err) + } + if removed, err := store.Collect(ctx); err != nil || removed != 0 { + t.Fatalf("collection removed %d reservations still retained: %v", removed, err) + } + time.Sleep(60 * time.Millisecond) + // Past its retention but inside the margin: still held. + if removed, err := store.Collect(ctx); err != nil || removed != 0 { + t.Fatalf("collection removed %d reservations inside the margin: %v", removed, err) + } + p.ahead = CollectMargin + time.Second + // Never finished: its holder stopped between Reserve and Finish. + if removed, err := store.Collect(ctx); err != nil || removed != 1 { + t.Fatalf("collected %d reservations past their retention: %v", removed, err) + } + if err := store.Reserve(ctx, "running", time.Now().Add(-time.Second)); !errors.Is(err, refusal.StampWindow) { + t.Fatalf("a reservation past its retention was not refused as the window: %v", err) + } + if _, held := p.held["running"]; held { + t.Fatal("a reservation past its retention revived a collected nonce") + } + outage := errors.New("connection refused") + if err := (&Store{pool: &pool{fail: outage}}).Reserve(ctx, "n", time.Now().Add(time.Minute)); !errors.Is(err, outage) { + t.Fatalf("an outage must not read as a replay: %v", err) + } + if err := store.Reserve(ctx, "", time.Now().Add(time.Minute)); err == nil { + t.Fatal("a reservation with no nonce was accepted") + } +} + +// The migration is the module's own, and the primary key is what makes the +// reservation atomic across replicas. +func TestTheMigrationReservesTheNonceAsAPrimaryKey(t *testing.T) { + for _, want := range []string{"hop_admissions", "nonce TEXT PRIMARY KEY", "retain_until", "active BOOLEAN NOT NULL DEFAULT FALSE"} { + if !contains(Migration, want) { + t.Fatalf("the migration does not declare %q: %s", want, Migration) + } + } +} + +func contains(haystack, needle string) bool { + for n := 0; n+len(needle) <= len(haystack); n++ { + if haystack[n:n+len(needle)] == needle { + return true + } + } + return false +} + +// Review r3 M2, on a real Postgres (HOP_TEST_POSTGRES): a holder that stops +// between Reserve and Finish leaves its row active for good, and collection +// still removes it once it is a margin past its retention by the database's +// clock; the nonce is not revived, since a reservation past its retention is +// refused. A row past its retention but inside the margin is kept. +func TestACrashedHoldersReservationIsCollectedOnTheDatabaseClock(t *testing.T) { + pg := postgres(t) + ctx := context.Background() + store, _ := New(pg) + crashed := fmt.Sprintf("crashed-holder-%d", time.Now().UnixNano()) + recent := crashed + "-recent" + // Rows as a crashed holder leaves them, aged by the database's own clock. + for nonce, age := range map[string]string{crashed: "61 seconds", recent: "30 seconds"} { + if _, err := pg.Exec(ctx, `INSERT INTO hop_admissions (nonce, retain_until, active) VALUES ($1, now() - $2::interval, TRUE)`, nonce, age); err != nil { + t.Fatal(err) + } + } + if _, err := store.Collect(ctx); err != nil { + t.Fatal(err) + } + if held(t, pg, crashed) { + t.Fatal("a crashed holder's reservation outlived its retention and the margin") + } + if !held(t, pg, recent) { + t.Fatal("collection removed a reservation inside the margin past its retention") + } + if err := store.Reserve(ctx, crashed, time.Now().Add(-time.Second)); !errors.Is(err, refusal.StampWindow) { + t.Fatalf("the collected nonce was reserved again: %v", err) + } +} + +// Review r4 N1, on a real Postgres: Reserve checks the retention and inserts +// in one statement but not one instant, so a Collect that removed a row the +// moment its retention passed could commit between the two and let the insert +// revive the nonce. With the margin, concurrent collection and reservation of +// a nonce near its expiry never admit it a second time. +func TestConcurrentCollectionNeverRevivesANonceNearItsExpiry(t *testing.T) { + pg := postgres(t) + ctx := context.Background() + store, _ := New(pg) + for round := range 20 { + nonce := fmt.Sprintf("near-expiry-%d-%d", time.Now().UnixNano(), round) + until := time.Now().Add(150 * time.Millisecond) + if err := store.Reserve(ctx, nonce, until); err != nil { + t.Fatal(err) + } + stop := time.Now().Add(400 * time.Millisecond) + done := make(chan error, 1) + go func() { + for time.Now().Before(stop) { + if _, err := store.Collect(ctx); err != nil { + done <- err + return + } + } + done <- nil + }() + for time.Now().Before(stop) { + switch err := store.Reserve(ctx, nonce, until); { + case err == nil: + t.Fatalf("round %d: a replay of a nonce near its expiry was admitted", round) + case errors.Is(err, core.ErrReplayed), errors.Is(err, refusal.StampWindow): + default: + t.Fatal(err) + } + } + if err := <-done; err != nil { + t.Fatal(err) + } + if !held(t, pg, nonce) { + t.Fatalf("round %d: the reservation was collected inside the margin", round) + } + } +} + +func postgres(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("HOP_TEST_POSTGRES") + if dsn == "" { + t.Skip("HOP_TEST_POSTGRES names no Postgres") + } + pg, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pg.Close) + if _, err := pg.Exec(context.Background(), Migration); err != nil { + t.Fatal(err) + } + return pg +} + +func held(t *testing.T, pg *pgxpool.Pool, nonce string) bool { + t.Helper() + var n int + if err := pg.QueryRow(context.Background(), `SELECT count(*) FROM hop_admissions WHERE nonce = $1`, nonce).Scan(&n); err != nil { + t.Fatal(err) + } + return n == 1 +} diff --git a/libraries/hop/go/routing/routes.go b/libraries/hop/go/routing/routes.go index c6be83fd..05341073 100644 --- a/libraries/hop/go/routing/routes.go +++ b/libraries/hop/go/routing/routes.go @@ -3,6 +3,7 @@ package routing import ( "encoding/json" + "errors" "fmt" "io" "net/url" @@ -108,6 +109,41 @@ func (r *Routing) Validate() error { } } } + return r.refuseTranscodedREST() +} + +// ErrTranscodedREST is the named refusal for a REST face a module transcodes +// into the listener its admission is checked on. The hop signs an HTTP route as +// "VERB /path", because that is what it decided and all a published http route +// tells it; a transcoded face hands the request to the same gRPC listener, +// whose verifier observes "/package.Service/Procedure". The two can never +// agree, so every REST call behind the hop would be refused as stamp_method +// after the authority had already admitted it. +// +// A module cannot work around it: verifying at the REST layer as well as the +// interceptor would consume the admission nonce twice and refuse as replayed, +// so exactly one layer may verify and the hop cannot tell which one will. +// Signing the procedure instead would need the published route to carry the +// template-to-procedure mapping its producer holds; the hop-routes schema +// carries no such field, so this is refused at startup rather than at every +// call. A module serving HTTP natively, with no gRPC or Connect face on the +// same service, is unaffected. +var ErrTranscodedREST = errors.New("hop routes: a REST face transcoded into a gRPC or Connect listener cannot be admitted behind the hop: the hop signs \"VERB /path\" for an http route while that listener's verifier observes the full procedure. Publish the module's HTTP face as its own service, or reach it through Connect") + +// refuseTranscodedREST refuses an http route published beside a gRPC or +// Connect route on the same listener's service: that is a transcoded REST face. +func (r *Routing) refuseTranscodedREST() error { + procedural := map[string]bool{} + for _, e := range r.Endpoints { + if e.Transport == "grpc" || e.Transport == "connect" { + procedural[e.Audience+"/"+e.Service] = true + } + } + for _, e := range r.Endpoints { + if e.Transport == "http" && procedural[e.Audience+"/"+e.Service] { + return fmt.Errorf("%w (%s/%s, endpoint %s)", ErrTranscodedREST, e.Audience, e.Service, e.Endpoint) + } + } return nil } diff --git a/libraries/hop/go/routing/routes_test.go b/libraries/hop/go/routing/routes_test.go index 60721a51..cead4583 100644 --- a/libraries/hop/go/routing/routes_test.go +++ b/libraries/hop/go/routing/routes_test.go @@ -2,6 +2,7 @@ package routing import ( "encoding/json" + "errors" "strings" "testing" ) @@ -126,6 +127,43 @@ func TestConnectGRPCOverlapRefusesInEitherOrder(t *testing.T) { } } +// Review r164 B2: a go-grpc service with rest-endpoint: true transcodes its +// REST face into the same listener the hop's verifier guards, so the hop's +// "VERB /path" admission can never match the procedure that listener observes. +// Every REST call behind the hop was refused as stamp_method after the +// authority had admitted it. The composition is refused when the hop starts +// instead, naming the reason. +func TestARESTFaceTranscodedIntoAProceduralListenerIsRefusedAtStartup(t *testing.T) { + for _, beside := range []string{"grpc", "connect"} { + t.Run("beside "+beside, func(t *testing.T) { + routes := &Routing{Schema: RoutingSchema, Endpoints: []RouteEndpoint{ + {Audience: "module", Service: "api", Endpoint: beside, Transport: beside, Address: "a", Package: "p", Version: "1", Digest: "d", Methods: []string{"/pkg.Service/Read"}}, + {Audience: "module", Service: "api", Endpoint: "rest", Transport: "http", Address: "a", Package: "p", Version: "1", Digest: "d", Methods: []string{"GET /read"}}, + }} + err := routes.Validate() + if !errors.Is(err, ErrTranscodedREST) { + t.Fatalf("want ErrTranscodedREST: %v", err) + } + if !strings.Contains(err.Error(), "module/api") { + t.Fatalf("the refusal names the listener: %v", err) + } + }) + } +} + +// A module serving HTTP natively keeps working: its http face is the only one +// on its service, so the verifier observes the same "VERB /path" the hop signs. +func TestANativeHTTPFaceIsAdmittedBesideOtherServicesProceduralFaces(t *testing.T) { + routes := &Routing{Schema: RoutingSchema, Endpoints: []RouteEndpoint{ + {Audience: "module", Service: "web", Endpoint: "http", Transport: "http", Address: "a", Package: "p", Version: "1", Digest: "d", Methods: []string{"GET /read"}}, + // Another service of the same module, procedural, is no transcoding. + {Audience: "module", Service: "api", Endpoint: "grpc", Transport: "grpc", Address: "b", Package: "p", Version: "1", Digest: "d", Methods: []string{"/pkg.Service/Read"}}, + }} + if err := routes.Validate(); err != nil { + t.Fatalf("a native HTTP face was refused: %v", err) + } +} + // A module that still publishes one service on a gRPC and a Connect endpoint // (the same contract) is one destination: gRPC calls go to Connect, which // serves gRPC, gRPC-Web and Connect, in either publication order. diff --git a/libraries/hop/go/transport_postgres_test.go b/libraries/hop/go/transport_postgres_test.go new file mode 100644 index 00000000..8cb46c18 --- /dev/null +++ b/libraries/hop/go/transport_postgres_test.go @@ -0,0 +1,289 @@ +package hop_test + +import ( + "context" + "net" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + hop "github.com/codefly-dev/authority/libraries/hop/go" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" + "github.com/codefly-dev/authority/libraries/hop/go/replaypg" + "github.com/codefly-dev/core/resources" + "github.com/jackc/pgx/v5/pgxpool" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/stats" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/emptypb" +) + +// postgresStore is the console's reservation store on a real Postgres, named +// by HOP_TEST_POSTGRES (a libpq connection string). CI starts one. +func postgresStore(t *testing.T) (*replaypg.Store, *pgxpool.Pool) { + t.Helper() + dsn := os.Getenv("HOP_TEST_POSTGRES") + if dsn == "" { + t.Skip("HOP_TEST_POSTGRES names no Postgres for the transport regression") + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + if _, err := pool.Exec(context.Background(), replaypg.Migration); err != nil { + t.Fatal(err) + } + store, err := replaypg.New(pool) + if err != nil { + t.Fatal(err) + } + return store, pool +} + +// transport is a real grpc-go server, installed the way a module installs the +// verifier, whose one unary method counts how often its handler ran. Calls are +// opened as raw streams so a test decides when each request body is sent. +type transport struct { + conn *grpc.ClientConn + arrived chan struct{} + reached atomic.Int64 + entered chan struct{} + hold chan struct{} + method string + metadata metadata.MD + expires time.Time // the admission's expiry plus the clock allowance + nonce string +} + +func newTransport(t *testing.T, ctx context.Context, store *replaypg.Store) *transport { + t.Helper() + tr := &transport{arrived: make(chan struct{}, 8), entered: make(chan struct{}, 8), hold: make(chan struct{}), method: "/regression.Service/Write"} + close(tr.hold) + authority := hoptest.New(t) + token := authority.IssueSingleUse(t, "regression") + tr.metadata = hoptest.Forward(t, token, "regression", tr.method) + // A short-lived admission, so its retention passes inside the test. + issued := time.Now() + proof, err := hoptest.Signer("regression").Sign(token, tr.method, "regression", issued, issued.Add(time.Second).Unix()) + if err != nil { + t.Fatal(err) + } + tr.metadata.Set(hop.ProofKey, proof) + call, err := stamp.Check(tr.metadata, hoptest.PublicKey("regression"), tr.method, "regression", issued) + if err != nil { + t.Fatal(err) + } + tr.nonce = call.ReplayKey(hoptest.PublicKey("regression")) + tr.expires = time.Unix(call.Expires+int64(stamp.Skew/time.Second), 0) + + v, err := hop.NewVerifier(ctx, authority.PublishHop("regression"), "regression", store) + if err != nil { + t.Fatal(err) + } + server := grpc.NewServer( + grpc.StatsHandler(arrivals{Handler: v.StatsHandler(), arrived: tr.arrived}), + grpc.ChainUnaryInterceptor(v.UnaryServerInterceptor())) + server.RegisterService(&grpc.ServiceDesc{ + ServiceName: "regression.Service", + HandlerType: (*any)(nil), + Methods: []grpc.MethodDesc{{MethodName: "Write", Handler: func(srv any, ctx context.Context, decode func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + request := new(emptypb.Empty) + if err := decode(request); err != nil { + return nil, err + } + return interceptor(ctx, request, &grpc.UnaryServerInfo{Server: srv, FullMethod: tr.method}, func(context.Context, any) (any, error) { + tr.reached.Add(1) + tr.entered <- struct{}{} + <-tr.hold + return &emptypb.Empty{}, nil + }) + }}}, + }, new(struct{})) + listener := bufconn.Listen(1 << 20) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + tr.conn, err = grpc.NewClient("passthrough:///regression", + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() })) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = tr.conn.Close() }) + return tr +} + +// arrivals is the verifier's stats handler, telling the test each call it has +// tagged on arrival. +type arrivals struct { + stats.Handler + arrived chan struct{} +} + +func (a arrivals) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context { + ctx = a.Handler.TagRPC(ctx, info) + a.arrived <- struct{}{} + return ctx +} + +// deliver opens one delivery of the admission and returns once the server has +// accepted its stream; its body is sent only when send is called. +func (tr *transport) deliver(t *testing.T, ctx context.Context) (send func() error) { + t.Helper() + stream, err := tr.conn.NewStream(metadata.NewOutgoingContext(ctx, tr.metadata.Copy()), &grpc.StreamDesc{}, tr.method) + if err != nil { + t.Fatal(err) + } + select { + case <-tr.arrived: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + return func() error { + if err := stream.SendMsg(&emptypb.Empty{}); err != nil { + return err + } + if err := stream.CloseSend(); err != nil { + return err + } + return stream.RecvMsg(&emptypb.Empty{}) + } +} + +// pastRetention waits until the admission's retention has passed, ages its +// reservation by replaypg.CollectMargin as if that much more time had passed +// (a database's clock cannot be advanced), and runs collection, as the +// console's schedule does. +func (tr *transport) pastRetention(t *testing.T, ctx context.Context, pool *pgxpool.Pool, store *replaypg.Store) { + t.Helper() + select { + case <-time.After(time.Until(tr.expires) + 200*time.Millisecond): + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if _, err := pool.Exec(ctx, `UPDATE hop_admissions SET retain_until = retain_until - make_interval(secs => $2) WHERE nonce = $1`, tr.nonce, replaypg.CollectMargin.Seconds()); err != nil { + t.Fatal(err) + } + if _, err := store.Collect(ctx); err != nil { + t.Fatal(err) + } +} + +// reserved waits, briefly, for the reservation of the delivery just opened, so +// the deliveries that follow it meet it in a known order. +func reserved(t *testing.T, ctx context.Context, pool *pgxpool.Pool, nonce string) { + t.Helper() + for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline) && !held(t, ctx, pool, nonce); { + time.Sleep(10 * time.Millisecond) + } +} + +func held(t *testing.T, ctx context.Context, pool *pgxpool.Pool, nonce string) bool { + t.Helper() + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM hop_admissions WHERE nonce = $1`, nonce).Scan(&count); err != nil { + t.Fatal(err) + } + return count == 1 +} + +// Review of #28 at 8a3e953b: two deliveries of one single-use admission arrive +// inside its window. The first runs. The second is held before its body is +// read until the admission's retention has passed and collection has run; +// then its body arrives. It must be refused by name, and the handler must have +// run exactly once. +func TestASecondDeliveryHeldPastCollectionIsRefusedOverTheRealTransport(t *testing.T) { + t.Setenv(resources.EnvironmentPrefix, "production") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + store, pool := postgresStore(t) + tr := newTransport(t, ctx, store) + + first := tr.deliver(t, ctx) + reserved(t, ctx, pool, tr.nonce) + second := tr.deliver(t, ctx) + if err := first(); err != nil { + t.Fatalf("the first delivery was refused: %v", err) + } + tr.pastRetention(t, ctx, pool, store) + err := second() + if err == nil || !strings.Contains(err.Error(), "stamp_replayed") { + t.Errorf("the second delivery was not refused as stamp_replayed: %v", err) + } + if got := tr.reached.Load(); got != 1 { + t.Fatalf("SECURITY: one single-use admission ran its handler %d times", got) + } +} + +// Review r3 M2: collection removes a reservation once its retention has +// passed, even while its call still runs (a crashed holder never finishes), +// and that reopens nothing: a delivery after the retention is refused on the +// window and by the database, and the running call completes. +func TestCollectingARunningCallsReservationReopensNothing(t *testing.T) { + t.Setenv(resources.EnvironmentPrefix, "production") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + store, pool := postgresStore(t) + tr := newTransport(t, ctx, store) + tr.hold = make(chan struct{}) + + first := tr.deliver(t, ctx) + result := make(chan error, 1) + go func() { result <- first() }() + select { + case <-tr.entered: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + tr.pastRetention(t, ctx, pool, store) + if held(t, ctx, pool, tr.nonce) { + t.Fatal("a reservation past its retention outlived collection") + } + if err := tr.deliver(t, ctx)(); err == nil || !strings.Contains(err.Error(), "stamp_window") { + t.Errorf("a delivery after the retention was not refused on the window: %v", err) + } + close(tr.hold) + if err := <-result; err != nil { + t.Fatalf("the running call was refused: %v", err) + } + if held(t, ctx, pool, tr.nonce) { + t.Fatal("finishing the call revived its collected reservation") + } + if got := tr.reached.Load(); got != 1 { + t.Fatalf("SECURITY: the handler ran %d times", got) + } +} + +// A delivery cancelled before its body arrived ran no handler, so its hold is +// released and collection removes the reservation once its retention passes; +// a second delivery inside the window is still refused. +func TestACallCancelledBeforeItsBodyReleasesItsHold(t *testing.T) { + t.Setenv(resources.EnvironmentPrefix, "production") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + store, pool := postgresStore(t) + tr := newTransport(t, ctx, store) + + cancelled, stop := context.WithCancel(ctx) + _ = tr.deliver(t, cancelled) + reserved(t, ctx, pool, tr.nonce) + second := tr.deliver(t, ctx) + stop() + if err := second(); err == nil || !strings.Contains(err.Error(), "stamp_replayed") { + t.Errorf("a second delivery inside the window was not refused as stamp_replayed: %v", err) + } + for { + tr.pastRetention(t, ctx, pool, store) + if !held(t, ctx, pool, tr.nonce) { + break + } + } + if got := tr.reached.Load(); got != 0 { + t.Fatalf("the handler ran %d times", got) + } +} diff --git a/libraries/hop/testdata/stamped.json b/libraries/hop/testdata/stamped.json index 55219c1d..950a4131 100644 --- a/libraries/hop/testdata/stamped.json +++ b/libraries/hop/testdata/stamped.json @@ -1,33 +1,40 @@ { - "proof": "fixture-proof", "audience": "documents", - "now": 1792818060, + "now": 1792818000, "keys": { "issuer": "https://authority.hop.test", "keys": [ { "keyId": "hop-fixture", "algorithm": "Ed25519", - "publicKey": "CViKnk6aO0k+3rS1SPdYggLCjBPmMG6s4UU/Y95Smqc=" + "publicKey": "CViKnk6aO0k+3rS1SPdYggLCjBPmMG6s4UU/Y95Smqc=", + "purpose": "work-context" + } + ], + "admissionKeys": [ + { + "keyId": "hop-c39261b4607295e9", + "algorithm": "Ed25519", + "publicKey": "FwU/dRlWglMCkljspfYdBcdmR+oL2RQ60b9fNuN9lik=", + "purpose": "hop-admission" } ] }, "headers": { "x-codefly-hop-actors": "", - "x-codefly-hop-advisory": "documents.collection:write is not held", - "x-codefly-hop-context": "cfaa9f0a79b969b344d63c29cded67e41791cee66339a9e59881708433b90c1b", + "x-codefly-hop-context": "978f0ed23f4c5a96367cb2b8bda07368a1018b5717ec671104eba7e9328ebab4", "x-codefly-hop-expires": "1792818600", "x-codefly-hop-owner": "principal-owner", "x-codefly-hop-owner-kind": "human", - "x-codefly-hop-proof": "fixture-proof", + "x-codefly-hop-proof": "WyJjb2RlZmx5LWhvcC12MSIsIi90ZXN0LlNlcnZpY2UvUmVhZCIsImRvY3VtZW50cyIsIjk3OGYwZWQyM2Y0YzVhOTYzNjdjYjJiOGJkYTA3MzY4YTEwMThiNTcxN2VjNjcxMTA0ZWJhN2U5MzI4ZWJhYjQiLCI1NzE5NTA4Yzc4ODMxODFhNjExNDk3ODg1YjBlNTA1OTE2NDcxNTI1Zjg4ZGZjNmFhYzExZTYxNzBlMjI0ZWZiIiwxNzkyODE4MDAwLDE3OTI4MTgwMzBd.G7tlrYUanR448OvpJ9KEDgL4XpaURINC0aefPXigdauI18NwMCeiexentKI1LuZHD7R0Dq_VR3Q1w2cr_ixYAA", "x-codefly-hop-scopes": "documents.collection:read", "x-codefly-hop-task": "task-conformance", "x-codefly-hop-tenant": "tenant-conformance", - "x-codefly-work-context": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoLaG9wLWZpeHR1cmUiGmh0dHBzOi8vYXV0aG9yaXR5LmhvcC50ZXN0Kglkb2N1bWVudHMw0Pbw1gY40Pbw1gZAqPvw1gZKJGJmZDRhNmM3LTcyNDAtNGUxNy1hZThhLTA4YjMxMWIyZDNhMVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokYzQ1N2YxM2UtMzM3Ny00ODYyLThiMjEtODliZmU3YmQ5ZWJmigEcChRkb2N1bWVudHMuY29sbGVjdGlvbhIEcmVhZIoBIgoUZG9jdW1lbnRzLmNvbGxlY3Rpb24SBXdyaXRlGgNjLTGyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.lRL9AGY2xuV56DtmS9vOXkaCPgtaVHlrnkzmdg9GBOvRjuQt8j8adscKdUGrsmX3vxCPmhCHi0vWp0t6noilAw" + "x-codefly-work-context": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoLaG9wLWZpeHR1cmUiGmh0dHBzOi8vYXV0aG9yaXR5LmhvcC50ZXN0Kglkb2N1bWVudHMw0Pbw1gY40Pbw1gZAqPvw1gZKJGFkODI5MzU0LWNlM2UtNDdjMS1hNGMzLWZiMTA5NWU3NjNlZFAHWgpzaW5nbGUtdXNlYhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokMjMwZjMwZWUtZmU4Zi00OTYxLTgxOTYtNjM0YTBjN2Q2OWQzigEcChRkb2N1bWVudHMuY29sbGVjdGlvbhIEcmVhZIoBIgoUZG9jdW1lbnRzLmNvbGxlY3Rpb24SBXdyaXRlGgNjLTGyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.NYofoki9BRAE-lS0wgUgLdGDYEiLj12BxCBM9RoyK0ldoWaue4iA8m6EPF2-qSm9RtW2Z0F1lwoazJ79O2FdCQ" }, "identity": { "actors": null, - "advisory": "documents.collection:write is not held", + "advisory": "", "binding": "", "expires": 1792818600, "owner": "principal-owner", diff --git a/modules/authority/module.codefly.yaml b/modules/authority/module.codefly.yaml index 7fcb0e6b..ff165f99 100644 --- a/modules/authority/module.codefly.yaml +++ b/modules/authority/module.codefly.yaml @@ -8,7 +8,7 @@ interface: - service: local endpoint: grpc implements: - - codefly.dev/authority@0.3.1 + - codefly.dev/authority@0.3.3 - codefly.dev/composition@0.2.0 - codefly.dev/installation@0.2.0 - codefly.dev/session@0.2.0 diff --git a/modules/authority/services/hop/README.md b/modules/authority/services/hop/README.md index 18b463f6..2bcd2c0f 100644 --- a/modules/authority/services/hop/README.md +++ b/modules/authority/services/hop/README.md @@ -22,8 +22,7 @@ several services. A request selects its destination, then makes one Authority Ch actual HTTP verb and path. The authority selects the policy from the delivered manifest, including stricter literal routes and ambiguous-route refusals. Connect uses a published procedure. A dotted URL alone never selects -Connect or borrows a gRPC policy. Unknown or ambiguous routes refuse, including -in warn mode. The request body, path, query and streaming protocol stay intact. +Connect or borrows a gRPC policy. Unknown or ambiguous routes refuse. The request body, path, query and streaming protocol stay intact. Routes are immutable for one Codefly run. Ordinary source reload retains the accepted endpoint addresses. A changed published route set or address requires @@ -34,39 +33,19 @@ agent may already have stopped or initialized its process. This does not promise live route-set adoption or restore that process. Failed targets remain unavailable; a sibling service is never their fallback. -The composition selects `hop/ENFORCEMENT` through SDK-delivered configuration: -`enforce` (the default) returns authority refusals; `warn` logs them and allows -permissive developer forwarding. For method-policy failures, warn mode requests -an advisory Check and uses the authority's returned context, preserving its -owner, tenant, operation binding, task and effective scopes. The refusal travels -in the stamp's advisory field and is logged once. +The hop always enforces the authority's method decision. Legacy +`hop/ENFORCEMENT=warn` and unknown modes refuse startup in every environment. +Advisory refusals never produce signed admissions. See the +[signed admission and replica storage contract](../../../../libraries/hop/README.md) +for Codefly key delivery, the exact procedure binding, and durable nonce consumption. -Missing, malformed, expired, revoked or misaddressed Work Contexts remain -refused in both modes. The hop also refuses when it cannot reach the authority. -Warning mode uses the authority's verified advisory context; it creates no -synthetic identity or developer session. The checked audience and selected -published route identify the upstream. +Validation: run `go vet ./...` and `go test -race ./...` in this service's +`code/`, the local authority's `code/`, and `libraries/hop/go`. The shared fixture +is also read by the Python and TypeScript regression suites. -Warning mode relaxes method-policy admission only. Issuance and downstream -resource, visibility and ownership decisions still apply. A successful hop unit -test alone does not prove a complete solution journey or enforce-mode qualification. - -Validation: run `go test -race ./...` in this service's `code/`, the local authority's -`code/`, and `libraries/hop/go`. Local authority's generated-client advisory test checks -the real issuer; proxy's routing regression checks that a Records request never -reaches Documents when its destination is unresolved. - -`ENFORCEMENT=warn` is accepted only when the SDK reports a local environment. -Unknown enforcement modes and warn in other environments are refused by name. -The shared authority client dialer uses verified TLS, or accepts an in-cluster -Service address under `internal-transport/mesh-protected=true`. Plaintext -loopback is local-only. It refuses missing or invalid mesh assertions for -in-cluster plaintext, including SDK-resolved fallback addresses. - -The HTTP face refuses non-canonical paths before admission: dot segments, -repeated separators, encoded dots/slashes/backslashes and nested escapes. -It never cleans and forwards a different path. There are no module-wide or -heuristic Connect fallback routes; every admitted call names a published listener. +The HTTP face always listens on its declared endpoint. A missing generated +`hop-routes/ROUTES` view is refused before starting either face. Every admitted +call names a published listener; there are no module-wide fallback routes. All hop-to-module endpoints use the same transport gate as the authority dialer: verified TLS, local loopback, or the composition's mesh-protected assertion plus diff --git a/modules/authority/services/hop/code/enforcement_test.go b/modules/authority/services/hop/code/enforcement_test.go index 55839c9a..c409f456 100644 --- a/modules/authority/services/hop/code/enforcement_test.go +++ b/modules/authority/services/hop/code/enforcement_test.go @@ -5,14 +5,10 @@ import ( "testing" ) -func TestEnforcementRequiresLocalEnvironment(t *testing.T) { - for _, local := range []bool{false, true} { - for _, mode := range []string{"", "enforce", "warn", "invalid"} { - warn, err := enforcementMode(mode, local) - denied := mode == "invalid" || mode == "warn" && !local - if (err != nil) != denied || warn != (mode == "warn" && local) { - t.Fatalf("mode %q local %v: warn %v, error %v", mode, local, warn, err) - } +func TestSignedAdmissionRequiresEnforcementInEveryEnvironment(t *testing.T) { + for _, mode := range []string{"", "enforce", "warn", "invalid"} { + if err := requireEnforcement(mode); (err != nil) != (mode == "warn" || mode == "invalid") { + t.Fatalf("mode %q: %v", mode, err) } } } diff --git a/modules/authority/services/hop/code/go.sum b/modules/authority/services/hop/code/go.sum index d6554232..f3736e2c 100644 --- a/modules/authority/services/hop/code/go.sum +++ b/modules/authority/services/hop/code/go.sum @@ -137,6 +137,14 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= @@ -208,6 +216,8 @@ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= diff --git a/modules/authority/services/hop/code/pkg/business/decide/decide.go b/modules/authority/services/hop/code/pkg/business/decide/decide.go index f86f0259..106fd480 100644 --- a/modules/authority/services/hop/code/pkg/business/decide/decide.go +++ b/modules/authority/services/hop/code/pkg/business/decide/decide.go @@ -41,15 +41,6 @@ type Decider struct { Routes *routing.Routing Authority Checker Modules map[string]bool - // Permissive is hop/ENFORCEMENT=warn: the authority is asked for advice, - // verifies the Work Context as always, and answers what the method's - // policy would have refused beside the verified identity; the call goes - // through stamped with that identity and the refusal as its advisory, - // which the hop also logs. An unsound or missing Work Context is still - // refused. The composition says so; a cell never does. - Permissive bool - // Warned is told each policy refusal the permissive hop let through. - Warned func(method string, err error) // AudienceOf reads the module a token is addressed to; nil decodes it with // core, unverified, which is all the hop needs before the authority checks. AudienceOf func(token string) (string, error) @@ -64,10 +55,6 @@ func coreAudience(token string) (string, error) { } // Decide returns the module a call is for and who it acts for, or the refusal. -// Permissive changes one answer only: the authority verified the Work Context -// and the method's policy would have refused it (decide's advisory path). A -// call whose Work Context is missing, unsound or addressed elsewhere, or that -// the authority could not be asked about, is refused in either mode. func (d Decider) Decide(ctx context.Context, method string, md metadata.MD) (Decision, error) { return d.decide(ctx, method, md) } @@ -101,7 +88,7 @@ func (d Decider) decide(ctx context.Context, method string, md metadata.MD) (Dec return Decision{}, status.Error(codes.NotFound, routeErr.Error()) } route := &selected - checked, err := d.Authority.Check(ctx, &authorityv1.CheckRequest{Token: tokens[0], Audience: module, Method: policy, Advisory: d.Permissive}) + checked, err := d.Authority.Check(ctx, &authorityv1.CheckRequest{Token: tokens[0], Audience: module, Method: policy}) if err != nil { refusal := status.Convert(err) if refusal.Code() == codes.Unavailable || refusal.Code() == codes.DeadlineExceeded { @@ -109,13 +96,10 @@ func (d Decider) decide(ctx context.Context, method string, md metadata.MD) (Dec } return Decision{}, status.Errorf(refusal.Code(), "the authority refused the call to %s: %s", module, refusal.Message()) } - id := IdentityOf(checked.GetContext()) - if d.Permissive && checked.GetRefusal() != "" { - id.Advisory = checked.GetRefusal() - if d.Warned != nil { - d.Warned(method, status.Error(codes.PermissionDenied, id.Advisory)) - } + if checked.GetRefusal() != "" { + return Decision{}, status.Error(codes.PermissionDenied, "the authority refused the method policy") } + id := IdentityOf(checked.GetContext()) return Decision{Module: module, Identity: id, Route: route}, nil } diff --git a/modules/authority/services/hop/code/pkg/business/decide/permissive_test.go b/modules/authority/services/hop/code/pkg/business/decide/permissive_test.go index 742fcdec..eafc4bd9 100644 --- a/modules/authority/services/hop/code/pkg/business/decide/permissive_test.go +++ b/modules/authority/services/hop/code/pkg/business/decide/permissive_test.go @@ -3,6 +3,7 @@ package decide import ( "context" "errors" + "testing" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" @@ -27,7 +28,7 @@ func (unsound) Check(context.Context, *authorityv1.CheckRequest, ...grpc.CallOpt // refused: a missing, ambiguous or undecodable Work Context, one addressed to a // module the hop does not front, one the authority refuses as unsound, and one // the authority could not be asked about. Nothing is stamped as a developer. -func TestAPermissiveHopStillRefusesWhatHasNoSoundIdentity(t *testing.T) { +func TestHopRefusesWhatHasNoSoundIdentity(t *testing.T) { audience := func(string) (string, error) { return "robin", nil } undecodable := func(string) (string, error) { return "", errors.New("not a Work Context") } for _, tc := range []struct { @@ -44,14 +45,13 @@ func TestAPermissiveHopStillRefusesWhatHasNoSoundIdentity(t *testing.T) { {"authority unreachable", Decider{Authority: unreachable{}, AudienceOf: audience}, metadata.Pairs(hop.WorkContextKey, "x"), codes.Unavailable}, } { t.Run(tc.name, func(t *testing.T) { - warnings := 0 d := tc.d d.Routes = testRoutes("robin", "GET /v1/robin/intents/version") - d.Modules, d.Permissive, d.Warned = map[string]bool{"robin": true}, true, func(string, error) { warnings++ } + d.Modules = map[string]bool{"robin": true} decision, err := d.Decide(context.Background(), "GET /v1/robin/intents/version", tc.md) module, id := decision.Module, decision.Identity - if status.Code(err) != tc.code || module != "" || id.Owner != "" || warnings != 0 { - t.Fatalf("module=%q id=%+v error=%v warnings=%d; want refused %v, nothing warned", module, id, err, warnings, tc.code) + if status.Code(err) != tc.code || module != "" || id.Owner != "" { + t.Fatalf("module=%q id=%+v error=%v; want refused %v", module, id, err, tc.code) } }) } @@ -79,40 +79,11 @@ func (advisoryChecker) Check(_ context.Context, req *authorityv1.CheckRequest, _ }}, nil } -// Permissive, a call the method's policy refuses goes through with the -// identity the authority verified and the refusal as the stamp's advisory, -// told to Warned. Enforcing, the same refusal stands. -func TestPermissivePolicyUsesTheAuthorityAdvisoryIdentity(t *testing.T) { - warnings := 0 - d := Decider{Routes: testRoutes("documents", "POST /documents/write"), Authority: advisoryChecker{}, Modules: map[string]bool{"documents": true}, Permissive: true, - AudienceOf: func(string) (string, error) { return "documents", nil }, - Warned: func(method string, err error) { - warnings++ - if method != "POST /documents/write" || status.Convert(err).Message() != "write scope missing" { - t.Errorf("warning = %q, %v", method, err) - } - }, - } - stamp := metadata.MD{} - decision, err := d.Decide(context.Background(), "POST /documents/write", metadata.Pairs(hop.WorkContextKey, "test-context")) - module, id := decision.Module, decision.Identity - if err != nil || module != "documents" || warnings != 1 { - t.Fatalf("decision = %q, %v; warnings = %d", module, err, warnings) - } - if id.Owner != "maya" || id.Tenant != "acme" || id.Binding != "acme/robin/documents" || id.Task != "task-1" || id.Expires != 42 { - t.Fatalf("lost authority identity: %+v", id) - } - if id.Advisory != "write scope missing" || len(id.Actors) != 1 || id.Actors[0] != "robin" || len(id.Scopes) != 1 || id.Scopes[0] != "documents:read" { - t.Fatalf("lost advisory or effective scopes: %+v", id) - } - hop.Stamp(stamp, id, "proof") - if got := stamp.Get(hop.AdvisoryKey); len(got) != 1 || got[0] != id.Advisory { - t.Fatalf("the advisory rides the stamp: %v", got) - } - +// A policy refusal can never become a signed downstream admission. +func TestPolicyRefusalStopsBeforeStamping(t *testing.T) { strict := Decider{Routes: testRoutes("documents", "POST /documents/write"), Authority: advisoryChecker{}, Modules: map[string]bool{"documents": true}, AudienceOf: func(string) (string, error) { return "documents", nil }} if _, err := strict.Decide(context.Background(), "POST /documents/write", metadata.Pairs(hop.WorkContextKey, "test-context")); status.Code(err) != codes.PermissionDenied { - t.Fatalf("enforcing, the policy refusal stands: %v", err) + t.Fatalf("policy refusal: %v", err) } } diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/canonical_path_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/canonical_path_test.go index 11f117fc..e4c01e8c 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/canonical_path_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/canonical_path_test.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "fmt" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" "io" "net" "net/http" @@ -12,6 +13,7 @@ import ( "strings" "sync/atomic" "testing" + "time" "github.com/codefly-dev/authority/libraries/hop/go" "github.com/codefly-dev/authority/libraries/hop/go/routing" @@ -25,9 +27,9 @@ func TestNonCanonicalPathsRefuseBeforeDecisionOverRawHTTP(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { forwarded.Add(1); w.WriteHeader(204) })) defer backend.Close() target, _ := url.Parse(backend.URL) - face := &proxy.HTTP{Proof: "test-proof", Upstreams: map[string]*url.URL{backend.URL: target}, Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { + face := &proxy.HTTP{Signer: hoptest.Signer("test-proof"), Upstreams: map[string]*url.URL{backend.URL: target}, Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { checked.Add(1) - return decide.Decision{Module: "module", Route: &routing.RouteEndpoint{Address: backend.URL}, Identity: hop.Identity{Owner: "person", Tenant: "tenant"}}, nil + return decide.Decision{Module: "module", Route: &routing.RouteEndpoint{Address: backend.URL}, Identity: hop.Identity{Expires: time.Now().Add(time.Minute).Unix(), Owner: "person", Tenant: "tenant"}}, nil }} server := httptest.NewServer(face) defer server.Close() diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/connect_listener_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/connect_listener_test.go index 0601276c..fb22221c 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/connect_listener_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/connect_listener_test.go @@ -4,7 +4,9 @@ import ( "connectrpc.com/connect" "context" "github.com/codefly-dev/authority/libraries/authority-client/go/host" + callstamp "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "github.com/codefly-dev/authority/libraries/hop/go" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" "github.com/codefly-dev/authority/libraries/hop/go/routing" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" @@ -19,15 +21,21 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "sync/atomic" "testing" + "time" ) type connectModule struct{ calls atomic.Int32 } func (m *connectModule) Version(_ context.Context, req *connect.Request[gen.VersionRequest]) (*connect.Response[gen.VersionResponse], error) { - if req.Header().Get(hop.ProofKey) != proof { - return nil, connect.NewError(connect.CodeUnauthenticated, hop.ReasonStampProof) + md := metadata.MD{} + for key, values := range req.Header() { + md[strings.ToLower(key)] = values + } + if _, err := callstamp.Check(md, hoptest.PublicKey(proof), genconnect.HopServiceVersionProcedure, "module", time.Now()); err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, err) } m.calls.Add(1) return connect.NewResponse(&gen.VersionResponse{Version: "one-listener"}), nil @@ -54,7 +62,7 @@ func TestGRPCAndConnectReachOnePublishedConnectListener(t *testing.T) { } checker := &routeChecker{} decider := decide.Decider{Routes: routes, Authority: checker, Modules: map[string]bool{"module": true}, AudienceOf: func(string) (string, error) { return "module", nil }} - p := &proxy.Proxy{Decide: decider.Decide, Proof: proof, Upstreams: map[string]*grpc.ClientConn{backend.URL: upstream}} + p := &proxy.Proxy{Decide: decider.Decide, Signer: hoptest.Signer(proof), Upstreams: map[string]*grpc.ClientConn{backend.URL: upstream}} conn := serve(t, p.ServerOptions(), func(*grpc.Server) {}) grpcClient := gen.NewHopServiceClient(conn) ctx := metadata.AppendToOutgoingContext(t.Context(), hop.WorkContextKey, "context") @@ -63,7 +71,7 @@ func TestGRPCAndConnectReachOnePublishedConnectListener(t *testing.T) { t.Fatalf("grpc: %v %v", got, err) } u, _ := url.Parse(backend.URL) - face := httptest.NewServer(&proxy.HTTP{Decide: decider.Decide, Proof: proof, Upstreams: map[string]*url.URL{backend.URL: u}}) + face := httptest.NewServer(&proxy.HTTP{Decide: decider.Decide, Signer: hoptest.Signer(proof), Upstreams: map[string]*url.URL{backend.URL: u}}) defer face.Close() client := genconnect.NewHopServiceClient(&http.Client{Transport: host.HTTPTransport()}, face.URL) req := connect.NewRequest(&gen.VersionRequest{}) @@ -114,7 +122,7 @@ func TestDualPublishedServiceSendsGRPCToItsConnectListener(t *testing.T) { t.Fatal(err) } decider := decide.Decider{Routes: routes, Authority: &routeChecker{}, Modules: map[string]bool{"module": true}, AudienceOf: func(string) (string, error) { return "module", nil }} - p := &proxy.Proxy{Decide: decider.Decide, Proof: proof, Upstreams: map[string]*grpc.ClientConn{connectURL: connectConn, grpcURL: grpcConn}} + p := &proxy.Proxy{Decide: decider.Decide, Signer: hoptest.Signer(proof), Upstreams: map[string]*grpc.ClientConn{connectURL: connectConn, grpcURL: grpcConn}} conn := serve(t, p.ServerOptions(), func(*grpc.Server) {}) ctx := metadata.AppendToOutgoingContext(t.Context(), hop.WorkContextKey, "context") if _, err := gen.NewHopServiceClient(conn).Version(ctx, &gen.VersionRequest{}); err != nil { diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/environment_proxy_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/environment_proxy_test.go index 20667dc9..612c49b8 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/environment_proxy_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/environment_proxy_test.go @@ -2,6 +2,7 @@ package proxy import ( "context" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" "net" "net/http" "net/http/httptest" @@ -43,6 +44,9 @@ func TestMain(m *testing.M) { } _ = os.Unsetenv("NO_PROXY") _ = os.Unsetenv("no_proxy") + // The modules these tests stand up keep their admissions in hoptest.Replay, + // a process-local store that only the run named exactly "local" may use. + _ = os.Setenv("CODEFLY__ENVIRONMENT", "local") code := m.Run() _ = listener.Close() os.Exit(code) @@ -53,10 +57,10 @@ func TestMain(m *testing.M) { func TestTheHTTPFaceNeverForwardsThroughAnEnvironmentProxy(t *testing.T) { target, _ := url.Parse("http://documents.ns.svc.cluster.local:8080") h := &HTTP{ - Proof: "run-proof", + Signer: hoptest.Signer("run-proof"), Upstreams: map[string]*url.URL{target.String(): target}, Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { - return decide.Decision{Route: &routing.RouteEndpoint{Address: target.String()}, Module: "documents", Identity: hop.Identity{Owner: "maya", Tenant: "acme"}}, nil + return decide.Decision{Route: &routing.RouteEndpoint{Address: target.String()}, Module: "documents", Identity: hop.Identity{Expires: time.Now().Add(time.Minute).Unix(), Owner: "maya", Tenant: "acme"}}, nil }, } before := proxied.Load() diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/generated_routing_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/generated_routing_test.go index 6ac61db7..3856d9fc 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/generated_routing_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/generated_routing_test.go @@ -2,6 +2,7 @@ package proxy_test import ( "context" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" "google.golang.org/grpc/health" healthv1 "google.golang.org/grpc/health/grpc_health_v1" "google.golang.org/grpc/test/bufconn" @@ -11,6 +12,7 @@ import ( "net/url" "sync/atomic" "testing" + "time" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" "github.com/codefly-dev/authority/libraries/hop/go" @@ -37,7 +39,7 @@ func (c *routeChecker) Check(_ context.Context, req *authorityv1.CheckRequest, _ if req.Method == c.denied { return nil, status.Error(codes.PermissionDenied, "declared route requires write") } - return &authorityv1.CheckResponse{Context: &authorityv1.WorkContextV1{OwnerPrincipalId: "person", TenantId: "tenant"}}, nil + return &authorityv1.CheckResponse{Context: &authorityv1.WorkContextV1{OwnerPrincipalId: "person", TenantId: "tenant", ExpiresAtUnix: time.Now().Add(time.Minute).Unix()}}, nil } func routeEndpoint(service, transport, address string, methods ...string) routing.RouteEndpoint { @@ -45,7 +47,7 @@ func routeEndpoint(service, transport, address string, methods ...string) routin } // A procedure-shaped HTTP path is not evidence of a Connect listener. Its -// matched HTTP policy and destination must be one decision, even in warn mode. +// matched HTTP policy and destination must be one decision. func TestPublishedDestinationAndPolicyAreOneSelection(t *testing.T) { var reached atomic.Int32 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -69,16 +71,15 @@ func TestPublishedDestinationAndPolicyAreOneSelection(t *testing.T) { } checker := &routeChecker{denied: "POST /gauge.v1.Service/Call"} decision := decide.Decider{Routes: routes, Authority: checker, Modules: map[string]bool{"module": true}, AudienceOf: func(string) (string, error) { return "module", nil }} - for _, warn := range []bool{false, true} { - decision.Permissive = warn + for range 2 { refused := 0 - face := proxy.HTTP{Decide: decision.Decide, Upstreams: map[string]*url.URL{backend.URL: u}, Proof: proof, Refused: func(string, error) { refused++ }} + face := proxy.HTTP{Decide: decision.Decide, Upstreams: map[string]*url.URL{backend.URL: u}, Signer: hoptest.Signer(proof), Refused: func(string, error) { refused++ }} req := httptest.NewRequest(http.MethodPost, "/gauge.v1.Service/Call?original=yes", nil) req.Header.Set(hop.WorkContextKey, "context") rec := httptest.NewRecorder() face.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden || reached.Load() != 0 || refused != 1 { - t.Fatalf("warn %v: status=%d reached=%d refused=%d", warn, rec.Code, reached.Load(), refused) + t.Fatalf("status=%d reached=%d refused=%d", rec.Code, reached.Load(), refused) } } if len(checker.calls) != 2 || checker.calls[0] != checker.denied || checker.calls[1] != checker.denied { @@ -86,7 +87,7 @@ func TestPublishedDestinationAndPolicyAreOneSelection(t *testing.T) { } // Inverting the grants admits the HTTP route despite the gRPC refusal. checker.denied = "/gauge.v1.Service/Call" - face := proxy.HTTP{Decide: decision.Decide, Upstreams: map[string]*url.URL{backend.URL: u}, Proof: proof} + face := proxy.HTTP{Decide: decision.Decide, Upstreams: map[string]*url.URL{backend.URL: u}, Signer: hoptest.Signer(proof)} req := httptest.NewRequest(http.MethodPost, "/gauge.v1.Service/Call", nil) req.Header.Set(hop.WorkContextKey, "context") rec := httptest.NewRecorder() @@ -115,7 +116,7 @@ func TestPublishedDestinationAndPolicyAreOneSelection(t *testing.T) { rec = httptest.NewRecorder() face.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized || reached.Load() != 1 { - t.Fatal("invalid identity reached a route in warn mode") + t.Fatal("invalid identity reached a route") } } @@ -135,7 +136,7 @@ func TestGeneratedGRPCRoutesKeepSiblingAndAudienceSeparate(t *testing.T) { routes.Endpoints = append(routes.Endpoints, routeEndpoint("health", "grpc", "third", "/grpc.health.v1.Health/Check", "/grpc.health.v1.Health/Watch")) checker := &routeChecker{} decision := decide.Decider{Routes: routes, Authority: checker, Modules: map[string]bool{"module": true, "other": true}, AudienceOf: func(token string) (string, error) { return token, nil }} - p := &proxy.Proxy{Decide: decision.Decide, Proof: proof, Upstreams: map[string]*grpc.ClientConn{"first": first, "second": second, "third": third, "module": second}} + p := &proxy.Proxy{Decide: decision.Decide, Signer: hoptest.Signer(proof), Upstreams: map[string]*grpc.ClientConn{"first": first, "second": second, "third": third, "module": second}} conn := serve(t, p.ServerOptions(), func(*grpc.Server) {}) for _, audience := range []string{"module", "other"} { ctx := metadata.AppendToOutgoingContext(t.Context(), hop.WorkContextKey, audience) diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/http.go b/modules/authority/services/hop/code/pkg/infra/proxy/http.go index 6b8bc29d..9b8a973e 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/http.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/http.go @@ -7,9 +7,11 @@ import ( "net/http/httputil" "net/url" "strings" + "time" "connectrpc.com/connect" "github.com/codefly-dev/authority/libraries/authority-client/go/host" + callstamp "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "github.com/codefly-dev/authority/libraries/hop/go" "github.com/codefly-dev/authority/libraries/hop/go/routing" "google.golang.org/grpc/codes" @@ -26,7 +28,7 @@ import ( type HTTP struct { Decide Decide Upstreams map[string]*url.URL - Proof string + Signer *callstamp.Signer // Refused is told every refusal, so the hop's log names it. Refused func(method string, err error) } @@ -65,7 +67,15 @@ func (h *HTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) { } stamp := metadata.MD{} stamp.Set(hop.WorkContextKey, md.Get(hop.WorkContextKey)...) - hop.Stamp(stamp, id, h.Proof) + boundMethod := method + // Connect's server interceptor observes the full procedure, not the HTTP verb. + if decision.Route.Transport == "connect" { + boundMethod = r.URL.Path + } + if err := hop.Stamp(stamp, id, h.Signer, boundMethod, module, time.Now()); err != nil { + refuse(w, r, status.Error(codes.Unauthenticated, err.Error())) + return + } (&httputil.ReverseProxy{ // Never through a proxy from the environment, which would see the stamp, // the run's proof and the forwarded Work Context. diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/http_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/http_test.go index 64dc4f2a..523e79eb 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/http_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/http_test.go @@ -2,6 +2,8 @@ package proxy import ( "context" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" + core "github.com/codefly-dev/core/workcontext" "hop/pkg/business/decide" "io" "net/http" @@ -9,6 +11,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/codefly-dev/authority/libraries/hop/go" "github.com/codefly-dev/authority/libraries/hop/go/routing" @@ -30,14 +33,14 @@ func TestAnHTTPRequestIsDecidedThenStampedAndForwarded(t *testing.T) { target, _ := url.Parse(module.URL) var decided string h := &HTTP{ - Proof: "run-proof", + Signer: hoptest.Signer("run-proof"), Upstreams: map[string]*url.URL{target.String(): target}, Decide: func(_ context.Context, method string, md metadata.MD) (decide.Decision, error) { decided = method if md.Get(hop.WorkContextKey)[0] != "wc-model" { t.Fatalf("the decision did not read the Work Context header: %v", md) } - return decide.Decision{Route: &routing.RouteEndpoint{Address: target.String()}, Module: "model", Identity: hop.Identity{Owner: "remi", OwnerKind: "human", Tenant: "acme", Scopes: []string{"modelservice.profiles:read"}}}, nil + return decide.Decision{Route: &routing.RouteEndpoint{Address: target.String()}, Module: "model", Identity: hop.Identity{Expires: time.Now().Add(time.Minute).Unix(), Owner: "remi", OwnerKind: "human", Tenant: "acme", Scopes: []string{"modelservice.profiles:read"}}}, nil }, } req := httptest.NewRequest(http.MethodGet, "/v1/model/profiles?x=1", nil) @@ -64,7 +67,7 @@ func TestAnHTTPRequestIsDecidedThenStampedAndForwarded(t *testing.T) { return "" } return v[0] - }; first(hop.TenantKey) != "acme" || first(hop.OwnerKey) != "remi" || first(hop.WorkContextKey) != "wc-model" || first(hop.ProofKey) != "run-proof" || first(hop.ContextKey) == "" { + }; first(hop.TenantKey) != "acme" || first(hop.OwnerKey) != "remi" || first(hop.WorkContextKey) != "wc-model" || first(hop.ProofKey) == "run-proof" || first(hop.ProofKey) == "" || first(hop.ContextKey) == "" { t.Fatalf("the hop stamped %v", md) } } @@ -81,7 +84,7 @@ func TestAnHTTPRefusalNeverReachesTheModule(t *testing.T) { codes.PermissionDenied: http.StatusForbidden, codes.Unavailable: http.StatusServiceUnavailable, } { - h := &HTTP{Proof: "p", Upstreams: map[string]*url.URL{target.String(): target}, + h := &HTTP{Signer: hoptest.Signer("p"), Upstreams: map[string]*url.URL{target.String(): target}, Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { return decide.Decision{}, status.Error(code, "the authority said why") }} @@ -96,10 +99,63 @@ func TestAnHTTPRefusalNeverReachesTheModule(t *testing.T) { } } +func TestHTTPAndConnectIssueTheBindingObservedByTheListener(t *testing.T) { + for _, transport := range []string{"http", "connect"} { + t.Run(transport, func(t *testing.T) { + const procedure = "/example.v1.Service/Read" + expected := procedure + if transport == "http" { + expected = "POST " + procedure + } + a := hoptest.New(t) + token := a.IssueSingleUse(t, "module") + decoded, err := core.Decode(token) + if err != nil { + t.Fatal(err) + } + verifier, err := hop.NewVerifier(context.Background(), a.PublishHop("binding-test"), "module", hoptest.Replay()) + if err != nil { + t.Fatal(err) + } + var captured metadata.MD + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = metadata.MD{} + for key, values := range r.Header { + captured[strings.ToLower(key)] = values + } + if _, err := verifier.Verify(r.Context(), captured, expected); err != nil { + t.Error(err) + w.WriteHeader(401) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + target, _ := url.Parse(backend.URL) + face := HTTP{Signer: hoptest.Signer("binding-test"), Upstreams: map[string]*url.URL{target.String(): target}, Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { + return decide.Decision{Module: "module", Identity: hop.IdentityOf(decoded.Context()), Route: &routing.RouteEndpoint{Transport: transport, Address: target.String()}}, nil + }} + req := httptest.NewRequest(http.MethodPost, procedure, nil) + req.Header.Set(hop.WorkContextKey, token) + rec := httptest.NewRecorder() + face.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("forwarded status %d", rec.Code) + } + if _, err := verifier.Verify(context.Background(), captured, expected); err == nil || !strings.Contains(err.Error(), "replay") { + t.Fatalf("captured replay: %v", err) + } + if _, err := verifier.Verify(context.Background(), captured, "/example.v1.Service/Delete"); err == nil || !strings.Contains(err.Error(), "stamp_method") { + t.Fatalf("retargeted stamp: %v", err) + } + }) + } +} + // A Connect caller is refused in Connect's words: its client reads the code // and the authority's reason, not a status line. func TestAConnectRefusalKeepsTheAuthoritysReason(t *testing.T) { - h := &HTTP{Proof: "p", Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { + h := &HTTP{Signer: hoptest.Signer("refusal"), Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { return decide.Decision{}, status.Error(codes.PermissionDenied, "the role lacks robin.tools:read") }} req := httptest.NewRequest(http.MethodPost, "/robin.chat.v1.ChatService/GetRunnableTools", strings.NewReader("{}")) diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/permissive_routing_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/permissive_routing_test.go index 479c46c5..42d7265d 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/permissive_routing_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/permissive_routing_test.go @@ -1,6 +1,7 @@ package proxy_test import ( + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" "net/http" "net/http/httptest" "net/url" @@ -15,7 +16,7 @@ import ( // This is a hermetic transport probe, not a replacement module or playground. // A routing failure may be reported, but a Records request must never go to // Documents merely because its name sorts first in the hop's module list. -func TestWarnModeDoesNotSendRecordsToDocuments(t *testing.T) { +func TestMissingContextDoesNotSendRecordsToDocuments(t *testing.T) { var documentsCalls atomic.Int32 documents := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { documentsCalls.Add(1) @@ -28,8 +29,8 @@ func TestWarnModeDoesNotSendRecordsToDocuments(t *testing.T) { defer records.Close() docsURL, _ := url.Parse(documents.URL) recordsURL, _ := url.Parse(records.URL) - decision := decide.Decider{Permissive: true, Modules: map[string]bool{"documents": true, "records": true}} - face := proxy.HTTP{Proof: "test-run-proof", Decide: decision.Decide, + decision := decide.Decider{Modules: map[string]bool{"documents": true, "records": true}} + face := proxy.HTTP{Signer: hoptest.Signer("test-run-proof"), Decide: decision.Decide, Upstreams: map[string]*url.URL{"documents": docsURL, "records": recordsURL}} req := httptest.NewRequest(http.MethodPost, "/records.v1.RecordsService/ReadRecord", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/proxy.go b/modules/authority/services/hop/code/pkg/infra/proxy/proxy.go index bf2e708b..a64d7e7c 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/proxy.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/proxy.go @@ -7,8 +7,10 @@ package proxy import ( "context" "errors" + callstamp "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "io" "strings" + "time" "github.com/codefly-dev/authority/libraries/hop/go" "google.golang.org/grpc" @@ -27,7 +29,7 @@ type Decide func(ctx context.Context, method string, md metadata.MD) (decide.Dec type Proxy struct { Decide Decide Upstreams map[string]*grpc.ClientConn - Proof string + Signer *callstamp.Signer // Refused is told every refusal, so the hop's log names it. Refused func(method string, err error) } @@ -62,7 +64,9 @@ func (p *Proxy) handle(_ any, in grpc.ServerStream) error { out[key] = values } } - hop.Stamp(out, id, p.Proof) + if err := hop.Stamp(out, id, p.Signer, method, module, time.Now()); err != nil { + return status.Error(codes.Unauthenticated, err.Error()) + } ctx, cancel := context.WithCancel(metadata.NewOutgoingContext(in.Context(), out)) defer cancel() if decision.Route == nil { diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/proxy_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/proxy_test.go index 482c3e58..8fcecdb3 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/proxy_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/proxy_test.go @@ -96,7 +96,7 @@ func dial(t *testing.T, listener *bufconn.Listener, opts ...grpc.DialOption) *gr func setup(t *testing.T) (through gen.HopServiceClient, direct gen.HopServiceClient, m *module, a *authority, refused *[]string) { m, a, refused = &module{}, &authority{Authority: hoptest.New(t), revoked: map[string]bool{}}, &[]string{} - verifier, err := hop.NewVerifier(context.Background(), a, "console", proof) + verifier, err := hop.NewVerifier(context.Background(), a.PublishHop(proof), "console", hoptest.Replay()) if err != nil { t.Fatal(err) } @@ -108,7 +108,7 @@ func setup(t *testing.T) (through gen.HopServiceClient, direct gen.HopServiceCli p := &proxy.Proxy{ Decide: decide.Decider{Routes: &routing.Routing{Endpoints: []routing.RouteEndpoint{{Audience: "console", Transport: "grpc", Address: "console", Methods: []string{"/api.HopService/Version"}}}}, Authority: a, Modules: map[string]bool{"console": true}}.Decide, Upstreams: map[string]*grpc.ClientConn{"console": dial(t, moduleListener, grpc.WithDefaultCallOptions(proxy.CallOptions()...))}, - Proof: proof, + Signer: hoptest.Signer(proof), Refused: func(method string, err error) { *refused = append(*refused, method+": "+err.Error()) }, } hopConn := serve(t, p.ServerOptions(), func(*grpc.Server) {}) @@ -162,7 +162,7 @@ func TestACallAroundTheHopIsRefusedByTheModule(t *testing.T) { // A peer that holds the proof stamps a context it forged: the module // authenticates the token itself, so the stamp alone is never enough. forged := hoptest.New(t).Issue(t, "console", identityRead) - _, err = direct.Version(metadata.NewOutgoingContext(context.Background(), hoptest.Forward(t, forged, proof)), &gen.VersionRequest{}) + _, err = direct.Version(metadata.NewOutgoingContext(context.Background(), hoptest.Forward(t, forged, proof, "/api.HopService/Version")), &gen.VersionRequest{}) if status.Code(err) != codes.Unauthenticated || !strings.Contains(err.Error(), "no verification key") || m.reached != 0 { t.Fatalf("a forged context with the proof: %v reached %d", err, m.reached) } diff --git a/modules/authority/services/hop/code/work.go b/modules/authority/services/hop/code/work.go index 8dec4dc8..40fed309 100644 --- a/modules/authority/services/hop/code/work.go +++ b/modules/authority/services/hop/code/work.go @@ -10,12 +10,14 @@ import ( "time" "github.com/codefly-dev/authority/libraries/authority-client/go/host" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "github.com/codefly-dev/core/standards" "github.com/codefly-dev/core/wool" codefly "github.com/codefly-dev/sdk-go" "google.golang.org/grpc" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + hop "github.com/codefly-dev/authority/libraries/hop/go" "github.com/codefly-dev/authority/libraries/hop/go/routing" "hop/pkg/adapters" @@ -29,9 +31,13 @@ func init() { func standInFront(ctx context.Context, config *adapters.Configuration) (Clean, error) { w := wool.Get(ctx).In("hop") - proof, err := codefly.For(ctx).WorkspaceSecret("hop", "PROOF") - if err != nil || proof == "" { - return nil, fmt.Errorf("hop: no run proof delivered (codefly config generate hop PROOF): %v", err) + seed, err := codefly.For(ctx).Secret("hop-signing", "SEED") + if err != nil || seed == "" { + return nil, fmt.Errorf("hop: no service signing seed delivered (codefly config generate hop-signing SEED --service authority/hop): %v", err) + } + signer, err := stamp.NewSigner(seed) + if err != nil { + return nil, err } // The authority, through the interface the hop depends on: the host the // composition names in authority/endpoint (saas accounts, say), else the @@ -116,29 +122,34 @@ func standInFront(ctx context.Context, config *adapters.Configuration) (Clean, e refused := func(method string, err error) { w.Warn("refused", wool.Field("method", method), wool.Field("reason", err.Error())) } - // hop/ENFORCEMENT: enforce (the default) refuses what the authority - // refuses; warn lets every call through stamped and logs what would have - // been refused — a composition's choice for a developer's run, never a - // cell's. + // Legacy warning mode must not issue an admission for a policy refusal. enforcement, _ := codefly.For(ctx).WorkspaceConfiguration("hop", "ENFORCEMENT") - permissive, err := enforcementMode(enforcement, codefly.IsLocal()) - if err != nil { + if err := requireEnforcement(enforcement); err != nil { return nil, err } - decider := decide.Decider{Routes: routes, Authority: authorityv1.NewAuthorityServiceClient(authority), Modules: modules, Permissive: permissive, - Warned: func(method string, err error) { - w.Warn("PERMISSIVE: let through a call the authority refused", wool.Field("method", method), wool.Field("reason", err.Error())) - }} + client := authorityv1.NewAuthorityServiceClient(authority) + // The authority publishes this hop's admission key once the hop registers + // it, proving itself with its workload credential: the seed stays here. + unregister := hop.Registration{Authority: client, Signer: signer, Credential: hop.RunCredential, Report: func(key *authorityv1.PublicKey, expires time.Time, err error) { + if err != nil { + w.Warn("the authority does not publish this hop's admission key, so modules behind it admit no call; retrying", wool.Field("reason", err.Error())) + return + } + w.Info("the authority publishes this hop's admission key", wool.Field("admission", key.GetKeyId()), wool.Field("lapses", expires.UTC().Format(time.RFC3339))) + }}.Keep(context.WithoutCancel(ctx)) + defer func() { + if !configured { + unregister() + } + }() + decider := decide.Decider{Routes: routes, Authority: client, Modules: modules} decision := decider.Decide - if permissive { - w.Warn("hop/ENFORCEMENT=warn: the hop lets every call through and logs what it would have refused") - } - p := &proxy.Proxy{Decide: decision, Upstreams: upstreams, Proof: proof, Refused: refused} + p := &proxy.Proxy{Decide: decision, Upstreams: upstreams, Signer: signer, Refused: refused} config.GRPCServerOptions = append(config.GRPCServerOptions, p.ServerOptions()...) // The service declares this endpoint even when every upstream uses gRPC. // Keep it listening so the declared readiness contract holds; the proxy // still refuses requests for which no authorized HTTP route exists. - stopWeb, err := serveHTTP(ctx, &proxy.HTTP{Decide: decision, Upstreams: webs, Proof: proof, Refused: refused}) + stopWeb, err := serveHTTP(ctx, &proxy.HTTP{Decide: decision, Upstreams: webs, Signer: signer, Refused: refused}) if err != nil { return nil, err } @@ -146,6 +157,7 @@ func standInFront(ctx context.Context, config *adapters.Configuration) (Clean, e configured = true return func() { stopWeb() + unregister() _ = authority.Close() for _, conn := range upstreams { _ = conn.Close() @@ -188,17 +200,12 @@ func requiredRoutes(raw string, readErr error) (*routing.Routing, error) { return routes, nil } -// enforcementMode refuses configuration mistakes and permits advice only locally. -func enforcementMode(value string, local bool) (bool, error) { +// requireEnforcement refuses legacy permissive configuration in every environment. +func requireEnforcement(value string) error { switch strings.ToLower(strings.TrimSpace(value)) { case "", "enforce": - return false, nil - case "warn": - if !local { - return false, fmt.Errorf("hop/ENFORCEMENT=warn requires a local environment; use enforce outside local runs") - } - return true, nil + return nil default: - return false, fmt.Errorf("hop/ENFORCEMENT must be enforce or warn") + return fmt.Errorf("hop/ENFORCEMENT must be enforce: signed admissions require enforced method policy") } } diff --git a/modules/authority/services/hop/code/work_test.go b/modules/authority/services/hop/code/work_test.go index 09ef391e..2c8988e8 100644 --- a/modules/authority/services/hop/code/work_test.go +++ b/modules/authority/services/hop/code/work_test.go @@ -13,13 +13,15 @@ import ( // Startup reads the same SDK carriers as a composed process. grpc.NewClient // is lazy: these refusals neither contact an authority nor start a proxy. func TestHopStartupRequiresGeneratedRoutes(t *testing.T) { + t.Setenv(resources.ModulePrefix, "authority") + t.Setenv(resources.ServicePrefix, "hop") t.Setenv(resources.EnvironmentPrefix, resources.LocalEnvironment().Name) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer listener.Close() - t.Setenv(resources.WorkspaceSecretConfigurationPrefix+"__HOP__PROOF", "controlled-test-proof") + t.Setenv(resources.ServiceSecretConfigurationPrefix+"__AUTHORITY__HOP__HOP_SIGNING__SEED", strings.Repeat("01", 32)) t.Setenv(resources.WorkspaceConfigurationPrefix+"__AUTHORITY__ENDPOINT", listener.Addr().String()) t.Setenv(resources.WorkspaceConfigurationPrefix+"__HOP__MODULES", "example") // Legacy values must not make an absent or invalid generated view acceptable. @@ -46,13 +48,15 @@ func TestHopStartupRequiresGeneratedRoutes(t *testing.T) { } func TestHopStartupRefusesUnprotectedUpstreamTransport(t *testing.T) { + t.Setenv(resources.ModulePrefix, "authority") + t.Setenv(resources.ServicePrefix, "hop") t.Setenv(resources.EnvironmentPrefix, resources.LocalEnvironment().Name) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer listener.Close() - t.Setenv(resources.WorkspaceSecretConfigurationPrefix+"__HOP__PROOF", "test-proof") + t.Setenv(resources.ServiceSecretConfigurationPrefix+"__AUTHORITY__HOP__HOP_SIGNING__SEED", strings.Repeat("01", 32)) t.Setenv(resources.WorkspaceConfigurationPrefix+"__AUTHORITY__ENDPOINT", listener.Addr().String()) t.Setenv(resources.WorkspaceConfigurationPrefix+"__HOP__MODULES", "example") for _, transport := range []string{"grpc", "http", "connect"} { diff --git a/modules/authority/services/hop/service.codefly.yaml b/modules/authority/services/hop/service.codefly.yaml index a7015317..2d5059ce 100644 --- a/modules/authority/services/hop/service.codefly.yaml +++ b/modules/authority/services/hop/service.codefly.yaml @@ -36,9 +36,12 @@ spec: # accounts, say), and the local authority when it names none (code/host.go). service-dependencies: - interface: codefly.dev/authority@>=0.2.0 <0.4.0 -# The hop group: the run's proof, which the hop writes on every stamp so the -# module behind it believes only stamps from this run's hop, and the module the -# composition puts the hop in front of (MODULES). hop-routes is generated +# The hop is a workload of its own: the run vouches for it, and it registers +# its admission key with the authority under that credential +# (RegisterAdmissionKey), so Keys publishes it to every module behind the hop. +module-identity: true +# The hop group names the modules this hop fronts (MODULES). Its signing seed +# is service-scoped hop-signing/SEED, never a workspace secret. hop-routes is generated # by Codefly from their published API catalogs, resolved only for this hop. workspace-configuration-dependencies: - hop diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 2c4cfb7c..ff78cb89 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -7,6 +7,7 @@ require ( buf.build/go/protovalidate v1.4.0 connectrpc.com/connect v1.21.0 connectrpc.com/vanguard v0.4.0 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026184108-e1966db4fe66 github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0 github.com/codefly-dev/authority/libraries/conformance/go v0.0.0 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0 @@ -33,7 +34,6 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v1.0.0 // indirect @@ -60,7 +60,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/kr/text v0.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/modules/authority/services/local/code/go.sum b/modules/authority/services/local/code/go.sum index 5938c187..daa295ed 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -38,21 +38,18 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 h1:QK5HW356hmRNwZJ1LmoDcKGxB0Jslx+EdBfXMHuJlvo= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026184108-e1966db4fe66 h1:hUN9kDZaqaTDJROIfQHzXOawIwKtH7mWm4dTwA0P81Q= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026184108-e1966db4fe66/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= -github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= -github.com/codefly-dev/sdk-go v0.4.0/go.mod h1:vExvoK8jqWNiUjmphgaK/ir1+Tz717mNS6bc4FAHVks= github.com/codefly-dev/sdk-go v0.4.3 h1:f7QHqqE7xqqpglOQ2kJ+DZCO/TbrNaDEZhGZ1IOd46Q= github.com/codefly-dev/sdk-go v0.4.3/go.mod h1:gviNmTzFn5UuK0CTQTxk9h11tkNXLgKu09NzuuPX5Eo= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -191,8 +188,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= @@ -236,16 +233,22 @@ go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9 go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= diff --git a/modules/authority/services/local/code/pkg/business/issuer/admission.go b/modules/authority/services/local/code/pkg/business/issuer/admission.go new file mode 100644 index 00000000..3bea01f6 --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/issuer/admission.go @@ -0,0 +1,88 @@ +package issuer + +import ( + "context" + "crypto/ed25519" + "fmt" + "sort" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/core/workcontext" + + authorityv1 "gateway/pkg/gen/host/authority/v1" +) + +// AdmissionLease is how long a registered admission key stays published +// without being registered again. The hop renews well inside it, so a +// restarted authority learns the key again within one renewal, and a key no +// running hop signs with any more is withdrawn once it lapses. +const AdmissionLease = 2 * time.Minute + +// maxAdmissionKeys bounds the keys published at once: a rotation needs two, +// and a few more absorb rotations closer together than one lease. +const maxAdmissionKeys = 8 + +// HopModule and HopService name the one workload whose admission keys this +// authority publishes: the hop service of the authority module. +const ( + HopModule = "authority" + HopService = "hop" +) + +type admission struct { + key *authorityv1.PublicKey + until time.Time +} + +// RegisterAdmissionKey records the public key the hop signs call admissions +// with, attesting that the caller is the hop, and publishes it in Keys until +// the answered time; registering it again renews it. Any other workload is +// refused by name, and only the public half is ever presented. +func (i *Issuer) RegisterAdmissionKey(ctx context.Context, credential string, public []byte) (*authorityv1.PublicKey, time.Time, error) { + workload, err := i.state.AttestWorkload(ctx, credential) + if err != nil { + return nil, time.Time{}, fmt.Errorf("an admission key is registered by the hop: %w", err) + } + if workload.Module != HopModule || workload.Service != HopService { + return nil, time.Time{}, domain.Refuse("the caller is %s/%s, not the hop (%s/%s): only the hop registers an admission key", + workload.Module, workload.Service, HopModule, HopService) + } + if len(public) != ed25519.PublicKeySize { + return nil, time.Time{}, domain.Malformed("an admission key is a %d-byte Ed25519 public key, not %d bytes", ed25519.PublicKeySize, len(public)) + } + key := &authorityv1.PublicKey{KeyId: stamp.KeyID(public), Algorithm: workcontext.Algorithm, PublicKey: append([]byte(nil), public...), Purpose: stamp.Purpose} + now := i.now() + until := now.Add(AdmissionLease) + i.mu.Lock() + defer i.mu.Unlock() + i.lapse(now) + if _, renewed := i.admission[key.KeyId]; !renewed && len(i.admission) >= maxAdmissionKeys { + return nil, time.Time{}, domain.NotNow("%d admission keys are already published; one must lapse before %s is registered", maxAdmissionKeys, key.KeyId) + } + i.admission[key.KeyId] = admission{key: key, until: until} + return key, until, nil +} + +// admissionKeys are the registered keys still within their lease, by key id. +func (i *Issuer) admissionKeys() []*authorityv1.PublicKey { + i.mu.Lock() + defer i.mu.Unlock() + i.lapse(i.now()) + out := make([]*authorityv1.PublicKey, 0, len(i.admission)) + for _, a := range i.admission { + out = append(out, a.key) + } + sort.Slice(out, func(a, b int) bool { return out[a].GetKeyId() < out[b].GetKeyId() }) + return out +} + +// lapse withdraws every registration past its lease. Called with mu held. +func (i *Issuer) lapse(now time.Time) { + for id, a := range i.admission { + if !now.Before(a.until) { + delete(i.admission, id) + } + } +} diff --git a/modules/authority/services/local/code/pkg/business/issuer/admission_test.go b/modules/authority/services/local/code/pkg/business/issuer/admission_test.go new file mode 100644 index 00000000..9894be4e --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/issuer/admission_test.go @@ -0,0 +1,164 @@ +package issuer_test + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + + "gateway/pkg/business/issuer" + "gateway/pkg/business/state" + "gateway/pkg/business/testkit" +) + +func hopSigner(t *testing.T, label string) *stamp.Signer { + t.Helper() + seed := sha256.Sum256([]byte(label)) + signer, err := stamp.NewSigner(hex.EncodeToString(seed[:])) + if err != nil { + t.Fatal(err) + } + return signer +} + +func published(k *testkit.Kit) []string { + var ids []string + for _, key := range k.Issuer.Keys().GetAdmissionKeys() { + ids = append(ids, key.GetKeyId()) + } + return ids +} + +// Review r4 B1': the hop registers its admission key with the authority under +// its own workload credential, and Keys publishes exactly that public key, as +// an admission key and never a Work Context key. Nothing about the hop is +// configured on the authority. +func TestTheHopsRegisteredKeyIsPublishedInKeys(t *testing.T) { + k := testkit.Bare(t) + if keys := k.Issuer.Keys().GetAdmissionKeys(); len(keys) != 0 { + t.Fatalf("an authority no hop registered with publishes %v", keys) + } + hop := hopSigner(t, "the-hop") + id, public := hop.Admission() + key, until, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public) + if err != nil { + t.Fatal(err) + } + if key.GetKeyId() != id || key.GetPurpose() != stamp.Purpose || key.GetAlgorithm() != "Ed25519" || hex.EncodeToString(key.GetPublicKey()) != hop.PublicKey() { + t.Fatalf("registered %v, not the hop's public key", key) + } + if lease := time.Until(until); lease <= 0 || lease > issuer.AdmissionLease { + t.Fatalf("the registration lapses in %s", lease) + } + keys := k.Issuer.Keys() + if got := keys.GetAdmissionKeys(); len(got) != 1 || got[0].GetKeyId() != id { + t.Fatalf("Keys publishes %v", got) + } + for _, workContextKey := range keys.GetKeys() { + if workContextKey.GetKeyId() == id { + t.Fatal("the hop's admission key is published as a Work Context key") + } + } +} + +// Only the hop registers an admission key: another composed workload is +// refused by name, and so is a credential the launcher did not sign. +func TestANonHopCallerIsRefusedByName(t *testing.T) { + k := testkit.Bare(t) + _, public := hopSigner(t, "impostor").Admission() + _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.Credential(t, "documents"), public) + if code, _ := domain.CodeOf(err); code != domain.Denied || !strings.Contains(err.Error(), "the caller is documents/documents, not the hop (authority/hop)") { + t.Fatalf("a module registered an admission key: %v", err) + } + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), "not-a-credential", public); !errors.Is(err, state.ErrForbidden) { + t.Fatalf("an unattested caller registered an admission key: %v", err) + } + if ids := published(k); len(ids) != 0 { + t.Fatalf("a refused registration published %v", ids) + } + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public[:16]); err == nil { + t.Fatal("a truncated key was registered") + } +} + +// A hop that restarts registers the same key again: one entry, renewed, never +// a second one. +func TestReRegistrationAtRestartIsIdempotent(t *testing.T) { + k := testkit.Bare(t) + clock := time.Now() + issuer.SetClock(k.Issuer, func() time.Time { return clock }) + _, public := hopSigner(t, "the-hop").Admission() + _, first, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public) + if err != nil { + t.Fatal(err) + } + clock = clock.Add(issuer.AdmissionLease / 2) + _, renewed, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public) + if err != nil || !renewed.After(first) { + t.Fatalf("the restart renewed to %s (was %s): %v", renewed, first, err) + } + clock = first.Add(time.Second) // the first lease alone would have lapsed + if ids := published(k); len(ids) != 1 { + t.Fatalf("Keys publishes %v after a re-registration", ids) + } +} + +// A rotation overlaps: while the retiring and the new key are both +// registered, both are published; once no hop renews the retiring key its +// lease lapses and it is withdrawn, and the new key stays. +func TestARotationOverlapsUntilTheRetiringKeyLapses(t *testing.T) { + k := testkit.Bare(t) + clock := time.Now() + issuer.SetClock(k.Issuer, func() time.Time { return clock }) + retiringID, retiring := hopSigner(t, "retiring").Admission() + nextID, next := hopSigner(t, "next").Admission() + for _, public := range [][]byte{retiring, next} { + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public); err != nil { + t.Fatal(err) + } + } + if ids := published(k); len(ids) != 2 { + t.Fatalf("the overlap publishes %v", ids) + } + // Only the new key's hops still run and renew. + for range 3 { + clock = clock.Add(issuer.AdmissionLease / 2) + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), next); err != nil { + t.Fatal(err) + } + } + if ids := published(k); len(ids) != 1 || ids[0] != nextID { + t.Fatalf("after the overlap Keys publishes %v, want only %s (withdrawn: %s)", ids, nextID, retiringID) + } + clock = clock.Add(issuer.AdmissionLease) + if ids := published(k); len(ids) != 0 { + t.Fatalf("a key no hop renews is still published: %v", ids) + } +} + +// The published set is bounded: past it a new key waits for one to lapse, +// refused by name, while a registered key still renews. +func TestThePublishedAdmissionKeysAreBounded(t *testing.T) { + k := testkit.Bare(t) + var last []byte + for n := 0; ; n++ { + _, public := hopSigner(t, "key-"+string(rune('a'+n))).Admission() + _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public) + if err == nil { + last = public + continue + } + if code, _ := domain.CodeOf(err); code != domain.Precondition || !strings.Contains(err.Error(), "already published") || n < 2 { + t.Fatalf("key %d: %v", n, err) + } + break + } + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), last); err != nil { + t.Fatalf("a registered key no longer renews at the bound: %v", err) + } +} diff --git a/modules/authority/services/local/code/pkg/business/issuer/export_test.go b/modules/authority/services/local/code/pkg/business/issuer/export_test.go new file mode 100644 index 00000000..70429953 --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/issuer/export_test.go @@ -0,0 +1,6 @@ +package issuer + +import "time" + +// SetClock moves the clock admission leases are judged by, for a test. +func SetClock(i *Issuer, now func() time.Time) { i.now = now } diff --git a/modules/authority/services/local/code/pkg/business/issuer/issuer.go b/modules/authority/services/local/code/pkg/business/issuer/issuer.go index 0525d9b0..3b9f5a0b 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/issuer.go +++ b/modules/authority/services/local/code/pkg/business/issuer/issuer.go @@ -11,6 +11,7 @@ import ( "crypto/ed25519" "errors" "fmt" + "sync" "time" "github.com/codefly-dev/authority/libraries/authority-core/go/authority" @@ -67,12 +68,18 @@ type Issuer struct { issuer string keyID string public ed25519.PublicKey + now func() time.Time + + mu sync.Mutex + // admission are the hop admission keys registered and not yet lapsed, by + // key id (RegisterAdmissionKey). + admission map[string]admission } // New builds the issuer for a host whose core signs as issuer with keyID, its // public half published by Keys. func New(st *state.State, issuer, keyID string, public ed25519.PublicKey) *Issuer { - return &Issuer{state: st, svc: st.Service(), issuer: issuer, keyID: keyID, public: public} + return &Issuer{state: st, svc: st.Service(), issuer: issuer, keyID: keyID, public: public, now: time.Now, admission: map[string]admission{}} } // Config is the core configuration for one signing key: what state.New @@ -84,11 +91,12 @@ func Config(issuer, keyID string, key ed25519.PrivateKey, now func() time.Time) return authority.Config{Issuer: issuer, KeyID: keyID, Signer: key, Clock: now, MaxTTL: MaxTTL, Replay: replay} } -// Keys is what a verifier pins. +// Keys is what a verifier pins: the Work Context key, and apart from it the +// admission keys the hop registered (RegisterAdmissionKey). func (i *Issuer) Keys() *authorityv1.KeysResponse { return &authorityv1.KeysResponse{Issuer: i.issuer, Keys: []*authorityv1.PublicKey{{ - KeyId: i.keyID, Algorithm: workcontext.Algorithm, PublicKey: []byte(i.public), - }}} + KeyId: i.keyID, Algorithm: workcontext.Algorithm, PublicKey: []byte(i.public), Purpose: "work-context", + }}, AdmissionKeys: i.admissionKeys()} } // ListResources answers which resources of kind a checked capability may act diff --git a/modules/authority/services/local/code/pkg/business/state/state.go b/modules/authority/services/local/code/pkg/business/state/state.go index 534041fc..0902cde7 100644 --- a/modules/authority/services/local/code/pkg/business/state/state.go +++ b/modules/authority/services/local/code/pkg/business/state/state.go @@ -23,6 +23,7 @@ import ( "github.com/codefly-dev/authority/libraries/hop/go/routing" resources "github.com/codefly-dev/authority/libraries/resources/go" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workloadidentity" "github.com/codefly-dev/core/workloadidentity/native" "github.com/google/uuid" "gopkg.in/yaml.v3" @@ -709,6 +710,17 @@ func (s *State) GrantRole(g domain.RoleGrant) error { return s.write(func(tx authority.Tx) error { return tx.PutRoleGrant(g) }) } +// AttestWorkload attests a native launcher credential with core's attester +// and answers the workload it names, composed module or not: for a caller the +// host knows by workload alone, as the hop registering its admission key. +func (s *State) AttestWorkload(ctx context.Context, credential string) (workloadidentity.Workload, error) { + attestation, err := s.attester.Attest(ctx, credential, s.audience) + if err != nil { + return workloadidentity.Workload{}, fmt.Errorf("%w: workload credential: %v", ErrForbidden, err) + } + return attestation.Workload, nil +} + // VerifyWorkload attests a native launcher credential with core's attester // and resolves the module it names to a composed module; for a signed // delivery the service must be one of its current declared workloads. diff --git a/modules/authority/services/local/code/pkg/business/testkit/testkit.go b/modules/authority/services/local/code/pkg/business/testkit/testkit.go index 05887187..4a17f4f0 100644 --- a/modules/authority/services/local/code/pkg/business/testkit/testkit.go +++ b/modules/authority/services/local/code/pkg/business/testkit/testkit.go @@ -56,6 +56,22 @@ func (k *Kit) Credential(t *testing.T, module string) string { return token } +// HopWorkload is the hop as the launcher starts it: the authority module's +// hop service, the one workload that registers admission keys. +func HopWorkload() workloadidentity.Workload { + return workloadidentity.Workload{TrustDomain: "local.test", Module: issuer.HopModule, Service: issuer.HopService, Instance: "hop-1"} +} + +// HopCredential is what the launcher signed for the hop's process. +func (k *Kit) HopCredential(t *testing.T) string { + t.Helper() + token, err := k.Launcher.Issue(native.Subject{Workload: HopWorkload(), Container: issuer.HopService}, Audience) + if err != nil { + t.Fatal(err) + } + return token +} + // Scope builds one scope. func Scope(kind string, actions ...string) *basev0.WorkScopeV1 { return &basev0.WorkScopeV1{ResourceKind: kind, Actions: actions} @@ -282,6 +298,7 @@ func Bare(t *testing.T) *Kit { for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation", "principal-a", "principal-b", "principal-c", "principal-store", "principal-legacy-store", "conformance-owner-a", "conformance-owner-b", "conformance-store"} { launcher.Declare(Workload(module)) } + launcher.Declare(HopWorkload()) _, signing, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go index 24747191..d580a1dd 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go @@ -983,8 +983,17 @@ type KeysResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The issuer every capability names. Issuer string `protobuf:"bytes,1,opt,name=issuer,proto3" json:"issuer,omitempty"` - // The public keys by key id. - Keys []*PublicKey `protobuf:"bytes,2,rep,name=keys,proto3" json:"keys,omitempty"` + // The public keys by key id: what a Work Context is signed with, and only + // that. Each carries purpose "work-context". + Keys []*PublicKey `protobuf:"bytes,2,rep,name=keys,proto3" json:"keys,omitempty"` + // The keys a hop signs each call's admission with, each with purpose + // "hop-admission": a module behind the hop verifies the admission by one of + // them. More than one is published while a hop's key is rotated, so the + // retiring and the new key overlap. They are never Work Context keys and are + // kept out of keys, so no verifier that reads keys alone can trust one to + // sign a Work Context. Empty until a hop registers its key + // (RegisterAdmissionKey). + AdmissionKeys []*PublicKey `protobuf:"bytes,3,rep,name=admission_keys,json=admissionKeys,proto3" json:"admission_keys,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1033,6 +1042,13 @@ func (x *KeysResponse) GetKeys() []*PublicKey { return nil } +func (x *KeysResponse) GetAdmissionKeys() []*PublicKey { + if x != nil { + return x.AdmissionKeys + } + return nil +} + // PublicKey is one verification key. type PublicKey struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1041,7 +1057,11 @@ type PublicKey struct { // The signature algorithm; Ed25519. Algorithm string `protobuf:"bytes,2,opt,name=algorithm,proto3" json:"algorithm,omitempty"` // The raw public key bytes. - PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + // What the key verifies: "work-context" (a Work Context) or "hop-admission" + // (a hop's signed call admission). A verifier uses a key only for its own + // purpose. + Purpose string `protobuf:"bytes,4,opt,name=purpose,proto3" json:"purpose,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1097,6 +1117,123 @@ func (x *PublicKey) GetPublicKey() []byte { return nil } +func (x *PublicKey) GetPurpose() string { + if x != nil { + return x.Purpose + } + return "" +} + +// RegisterAdmissionKeyRequest presents one hop admission key. +type RegisterAdmissionKeyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The hop's workload credential; any other workload is refused. + Actor *WorkloadCredential `protobuf:"bytes,1,opt,name=actor,proto3" json:"actor,omitempty"` + // The raw Ed25519 public key the hop signs admissions with: 32 bytes. + PublicKey []byte `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterAdmissionKeyRequest) Reset() { + *x = RegisterAdmissionKeyRequest{} + mi := &file_host_authority_v1_authority_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterAdmissionKeyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterAdmissionKeyRequest) ProtoMessage() {} + +func (x *RegisterAdmissionKeyRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_authority_v1_authority_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterAdmissionKeyRequest.ProtoReflect.Descriptor instead. +func (*RegisterAdmissionKeyRequest) Descriptor() ([]byte, []int) { + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{16} +} + +func (x *RegisterAdmissionKeyRequest) GetActor() *WorkloadCredential { + if x != nil { + return x.Actor + } + return nil +} + +func (x *RegisterAdmissionKeyRequest) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + +// RegisterAdmissionKeyResponse is the key as Keys now publishes it. +type RegisterAdmissionKeyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The published entry, with its key id and purpose "hop-admission". + Key *PublicKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // When the registration lapses unless the hop registers the key again. + ExpiresAtUnix int64 `protobuf:"varint,2,opt,name=expires_at_unix,json=expiresAtUnix,proto3" json:"expires_at_unix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterAdmissionKeyResponse) Reset() { + *x = RegisterAdmissionKeyResponse{} + mi := &file_host_authority_v1_authority_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterAdmissionKeyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterAdmissionKeyResponse) ProtoMessage() {} + +func (x *RegisterAdmissionKeyResponse) ProtoReflect() protoreflect.Message { + mi := &file_host_authority_v1_authority_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterAdmissionKeyResponse.ProtoReflect.Descriptor instead. +func (*RegisterAdmissionKeyResponse) Descriptor() ([]byte, []int) { + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{17} +} + +func (x *RegisterAdmissionKeyResponse) GetKey() *PublicKey { + if x != nil { + return x.Key + } + return nil +} + +func (x *RegisterAdmissionKeyResponse) GetExpiresAtUnix() int64 { + if x != nil { + return x.ExpiresAtUnix + } + return 0 +} + // ListResourcesRequest asks about one kind and one action. type ListResourcesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1114,7 +1251,7 @@ type ListResourcesRequest struct { func (x *ListResourcesRequest) Reset() { *x = ListResourcesRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[16] + mi := &file_host_authority_v1_authority_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1126,7 +1263,7 @@ func (x *ListResourcesRequest) String() string { func (*ListResourcesRequest) ProtoMessage() {} func (x *ListResourcesRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[16] + mi := &file_host_authority_v1_authority_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1139,7 +1276,7 @@ func (x *ListResourcesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListResourcesRequest.ProtoReflect.Descriptor instead. func (*ListResourcesRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{16} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{18} } func (x *ListResourcesRequest) GetToken() string { @@ -1182,7 +1319,7 @@ type ListResourcesResponse struct { func (x *ListResourcesResponse) Reset() { *x = ListResourcesResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[17] + mi := &file_host_authority_v1_authority_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1194,7 +1331,7 @@ func (x *ListResourcesResponse) String() string { func (*ListResourcesResponse) ProtoMessage() {} func (x *ListResourcesResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[17] + mi := &file_host_authority_v1_authority_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1207,7 +1344,7 @@ func (x *ListResourcesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListResourcesResponse.ProtoReflect.Descriptor instead. func (*ListResourcesResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{17} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{19} } func (x *ListResourcesResponse) GetGrants() []*ResourceGrant { @@ -1234,7 +1371,7 @@ type AttachResourceRequest struct { func (x *AttachResourceRequest) Reset() { *x = AttachResourceRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1246,7 +1383,7 @@ func (x *AttachResourceRequest) String() string { func (*AttachResourceRequest) ProtoMessage() {} func (x *AttachResourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1259,7 +1396,7 @@ func (x *AttachResourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachResourceRequest.ProtoReflect.Descriptor instead. func (*AttachResourceRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{18} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{20} } func (x *AttachResourceRequest) GetToken() string { @@ -1301,7 +1438,7 @@ type AttachResourceResponse struct { func (x *AttachResourceResponse) Reset() { *x = AttachResourceResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[19] + mi := &file_host_authority_v1_authority_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1313,7 +1450,7 @@ func (x *AttachResourceResponse) String() string { func (*AttachResourceResponse) ProtoMessage() {} func (x *AttachResourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[19] + mi := &file_host_authority_v1_authority_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1326,7 +1463,7 @@ func (x *AttachResourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachResourceResponse.ProtoReflect.Descriptor instead. func (*AttachResourceResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{19} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{21} } func (x *AttachResourceResponse) GetInstallationId() string { @@ -1353,7 +1490,7 @@ type DetachResourceRequest struct { func (x *DetachResourceRequest) Reset() { *x = DetachResourceRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[20] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1365,7 +1502,7 @@ func (x *DetachResourceRequest) String() string { func (*DetachResourceRequest) ProtoMessage() {} func (x *DetachResourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[20] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1378,7 +1515,7 @@ func (x *DetachResourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DetachResourceRequest.ProtoReflect.Descriptor instead. func (*DetachResourceRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{20} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{22} } func (x *DetachResourceRequest) GetActor() *WorkloadCredential { @@ -1420,7 +1557,7 @@ type DetachResourceResponse struct { func (x *DetachResourceResponse) Reset() { *x = DetachResourceResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[21] + mi := &file_host_authority_v1_authority_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1432,7 +1569,7 @@ func (x *DetachResourceResponse) String() string { func (*DetachResourceResponse) ProtoMessage() {} func (x *DetachResourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[21] + mi := &file_host_authority_v1_authority_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1445,7 +1582,7 @@ func (x *DetachResourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DetachResourceResponse.ProtoReflect.Descriptor instead. func (*DetachResourceResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{21} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{23} } func (x *DetachResourceResponse) GetInstallationId() string { @@ -1477,7 +1614,7 @@ type AuthorizeTransferRequest struct { func (x *AuthorizeTransferRequest) Reset() { *x = AuthorizeTransferRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[22] + mi := &file_host_authority_v1_authority_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1489,7 +1626,7 @@ func (x *AuthorizeTransferRequest) String() string { func (*AuthorizeTransferRequest) ProtoMessage() {} func (x *AuthorizeTransferRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[22] + mi := &file_host_authority_v1_authority_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1502,7 +1639,7 @@ func (x *AuthorizeTransferRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizeTransferRequest.ProtoReflect.Descriptor instead. func (*AuthorizeTransferRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{22} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{24} } func (x *AuthorizeTransferRequest) GetToken() string { @@ -1560,7 +1697,7 @@ type AuthorizeTransferResponse struct { func (x *AuthorizeTransferResponse) Reset() { *x = AuthorizeTransferResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[23] + mi := &file_host_authority_v1_authority_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1572,7 +1709,7 @@ func (x *AuthorizeTransferResponse) String() string { func (*AuthorizeTransferResponse) ProtoMessage() {} func (x *AuthorizeTransferResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[23] + mi := &file_host_authority_v1_authority_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1585,7 +1722,7 @@ func (x *AuthorizeTransferResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizeTransferResponse.ProtoReflect.Descriptor instead. func (*AuthorizeTransferResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{23} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{25} } func (x *AuthorizeTransferResponse) GetPrincipalId() string { @@ -1622,7 +1759,7 @@ type ResourceGrant struct { func (x *ResourceGrant) Reset() { *x = ResourceGrant{} - mi := &file_host_authority_v1_authority_proto_msgTypes[24] + mi := &file_host_authority_v1_authority_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1634,7 +1771,7 @@ func (x *ResourceGrant) String() string { func (*ResourceGrant) ProtoMessage() {} func (x *ResourceGrant) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[24] + mi := &file_host_authority_v1_authority_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1647,7 +1784,7 @@ func (x *ResourceGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceGrant.ProtoReflect.Descriptor instead. func (*ResourceGrant) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{24} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{26} } func (x *ResourceGrant) GetMatch() isResourceGrant_Match { @@ -1801,15 +1938,24 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\acontext\x18\x01 \x01(\v2 .host.authority.v1.WorkContextV1R\acontext\x125\n" + "\x16authorization_revision\x18\x02 \x01(\x04R\x15authorizationRevision\x12\x18\n" + "\arefusal\x18\x03 \x01(\tR\arefusal\"\r\n" + - "\vKeysRequest\"X\n" + + "\vKeysRequest\"\x9d\x01\n" + "\fKeysResponse\x12\x16\n" + "\x06issuer\x18\x01 \x01(\tR\x06issuer\x120\n" + - "\x04keys\x18\x02 \x03(\v2\x1c.host.authority.v1.PublicKeyR\x04keys\"_\n" + + "\x04keys\x18\x02 \x03(\v2\x1c.host.authority.v1.PublicKeyR\x04keys\x12C\n" + + "\x0eadmission_keys\x18\x03 \x03(\v2\x1c.host.authority.v1.PublicKeyR\radmissionKeys\"y\n" + "\tPublicKey\x12\x15\n" + "\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1c\n" + "\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n" + "\n" + - "public_key\x18\x03 \x01(\fR\tpublicKey\"\x85\x01\n" + + "public_key\x18\x03 \x01(\fR\tpublicKey\x12\x18\n" + + "\apurpose\x18\x04 \x01(\tR\apurpose\"y\n" + + "\x1bRegisterAdmissionKeyRequest\x12;\n" + + "\x05actor\x18\x01 \x01(\v2%.host.authority.v1.WorkloadCredentialR\x05actor\x12\x1d\n" + + "\n" + + "public_key\x18\x02 \x01(\fR\tpublicKey\"v\n" + + "\x1cRegisterAdmissionKeyResponse\x12.\n" + + "\x03key\x18\x01 \x01(\v2\x1c.host.authority.v1.PublicKeyR\x03key\x12&\n" + + "\x0fexpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\"\x85\x01\n" + "\x14ListResourcesRequest\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n" + "\baudience\x18\x02 \x01(\tR\baudience\x12#\n" + @@ -1850,11 +1996,12 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n" + "\apattern\x18\x04 \x01(\tH\x00R\apattern\x12*\n" + "\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\a\n" + - "\x05match2\xea\a\n" + + "\x05match2\xe3\b\n" + "\x10AuthorityService\x12j\n" + "\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/v1/authority:issue\x12j\n" + "\x05Check\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/v1/authority:check\x12G\n" + - "\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12b\n" + + "\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12w\n" + + "\x14RegisterAdmissionKey\x12..host.authority.v1.RegisterAdmissionKeyRequest\x1a/.host.authority.v1.RegisterAdmissionKeyResponse\x12b\n" + "\rListResources\x12'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12e\n" + "\x0eAttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12e\n" + "\x0eDetachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n" + @@ -1875,79 +2022,86 @@ func file_host_authority_v1_authority_proto_rawDescGZIP() []byte { return file_host_authority_v1_authority_proto_rawDescData } -var file_host_authority_v1_authority_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_host_authority_v1_authority_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_host_authority_v1_authority_proto_goTypes = []any{ - (*IssueRequest)(nil), // 0: host.authority.v1.IssueRequest - (*DelegationRef)(nil), // 1: host.authority.v1.DelegationRef - (*DelegateRequest)(nil), // 2: host.authority.v1.DelegateRequest - (*Delegation)(nil), // 3: host.authority.v1.Delegation - (*GetDelegationRequest)(nil), // 4: host.authority.v1.GetDelegationRequest - (*RevokeDelegationRequest)(nil), // 5: host.authority.v1.RevokeDelegationRequest - (*RevokeDelegationResponse)(nil), // 6: host.authority.v1.RevokeDelegationResponse - (*PersonSession)(nil), // 7: host.authority.v1.PersonSession - (*WorkloadCredential)(nil), // 8: host.authority.v1.WorkloadCredential - (*InstallationRef)(nil), // 9: host.authority.v1.InstallationRef - (*IssueResponse)(nil), // 10: host.authority.v1.IssueResponse - (*CheckRequest)(nil), // 11: host.authority.v1.CheckRequest - (*CheckResponse)(nil), // 12: host.authority.v1.CheckResponse - (*KeysRequest)(nil), // 13: host.authority.v1.KeysRequest - (*KeysResponse)(nil), // 14: host.authority.v1.KeysResponse - (*PublicKey)(nil), // 15: host.authority.v1.PublicKey - (*ListResourcesRequest)(nil), // 16: host.authority.v1.ListResourcesRequest - (*ListResourcesResponse)(nil), // 17: host.authority.v1.ListResourcesResponse - (*AttachResourceRequest)(nil), // 18: host.authority.v1.AttachResourceRequest - (*AttachResourceResponse)(nil), // 19: host.authority.v1.AttachResourceResponse - (*DetachResourceRequest)(nil), // 20: host.authority.v1.DetachResourceRequest - (*DetachResourceResponse)(nil), // 21: host.authority.v1.DetachResourceResponse - (*AuthorizeTransferRequest)(nil), // 22: host.authority.v1.AuthorizeTransferRequest - (*AuthorizeTransferResponse)(nil), // 23: host.authority.v1.AuthorizeTransferResponse - (*ResourceGrant)(nil), // 24: host.authority.v1.ResourceGrant - (*WorkScopeV1)(nil), // 25: host.authority.v1.WorkScopeV1 - (*WorkContextV1)(nil), // 26: host.authority.v1.WorkContextV1 + (*IssueRequest)(nil), // 0: host.authority.v1.IssueRequest + (*DelegationRef)(nil), // 1: host.authority.v1.DelegationRef + (*DelegateRequest)(nil), // 2: host.authority.v1.DelegateRequest + (*Delegation)(nil), // 3: host.authority.v1.Delegation + (*GetDelegationRequest)(nil), // 4: host.authority.v1.GetDelegationRequest + (*RevokeDelegationRequest)(nil), // 5: host.authority.v1.RevokeDelegationRequest + (*RevokeDelegationResponse)(nil), // 6: host.authority.v1.RevokeDelegationResponse + (*PersonSession)(nil), // 7: host.authority.v1.PersonSession + (*WorkloadCredential)(nil), // 8: host.authority.v1.WorkloadCredential + (*InstallationRef)(nil), // 9: host.authority.v1.InstallationRef + (*IssueResponse)(nil), // 10: host.authority.v1.IssueResponse + (*CheckRequest)(nil), // 11: host.authority.v1.CheckRequest + (*CheckResponse)(nil), // 12: host.authority.v1.CheckResponse + (*KeysRequest)(nil), // 13: host.authority.v1.KeysRequest + (*KeysResponse)(nil), // 14: host.authority.v1.KeysResponse + (*PublicKey)(nil), // 15: host.authority.v1.PublicKey + (*RegisterAdmissionKeyRequest)(nil), // 16: host.authority.v1.RegisterAdmissionKeyRequest + (*RegisterAdmissionKeyResponse)(nil), // 17: host.authority.v1.RegisterAdmissionKeyResponse + (*ListResourcesRequest)(nil), // 18: host.authority.v1.ListResourcesRequest + (*ListResourcesResponse)(nil), // 19: host.authority.v1.ListResourcesResponse + (*AttachResourceRequest)(nil), // 20: host.authority.v1.AttachResourceRequest + (*AttachResourceResponse)(nil), // 21: host.authority.v1.AttachResourceResponse + (*DetachResourceRequest)(nil), // 22: host.authority.v1.DetachResourceRequest + (*DetachResourceResponse)(nil), // 23: host.authority.v1.DetachResourceResponse + (*AuthorizeTransferRequest)(nil), // 24: host.authority.v1.AuthorizeTransferRequest + (*AuthorizeTransferResponse)(nil), // 25: host.authority.v1.AuthorizeTransferResponse + (*ResourceGrant)(nil), // 26: host.authority.v1.ResourceGrant + (*WorkScopeV1)(nil), // 27: host.authority.v1.WorkScopeV1 + (*WorkContextV1)(nil), // 28: host.authority.v1.WorkContextV1 } var file_host_authority_v1_authority_proto_depIdxs = []int32{ 7, // 0: host.authority.v1.IssueRequest.person:type_name -> host.authority.v1.PersonSession 8, // 1: host.authority.v1.IssueRequest.workload:type_name -> host.authority.v1.WorkloadCredential 9, // 2: host.authority.v1.IssueRequest.installation:type_name -> host.authority.v1.InstallationRef 1, // 3: host.authority.v1.IssueRequest.delegation:type_name -> host.authority.v1.DelegationRef - 25, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 27, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 5: host.authority.v1.IssueRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 25, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 - 25, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 + 27, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 27, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 8: host.authority.v1.GetDelegationRequest.actor:type_name -> host.authority.v1.WorkloadCredential 3, // 9: host.authority.v1.RevokeDelegationResponse.delegation:type_name -> host.authority.v1.Delegation - 26, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 - 26, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 + 28, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 + 28, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 15, // 12: host.authority.v1.KeysResponse.keys:type_name -> host.authority.v1.PublicKey - 24, // 13: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant - 8, // 14: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 8, // 15: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 8, // 16: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 0, // 17: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest - 11, // 18: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest - 13, // 19: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest - 16, // 20: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest - 18, // 21: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest - 20, // 22: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest - 22, // 23: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest - 2, // 24: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest - 5, // 25: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest - 4, // 26: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest - 10, // 27: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse - 12, // 28: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse - 14, // 29: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse - 17, // 30: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse - 19, // 31: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse - 21, // 32: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse - 23, // 33: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse - 3, // 34: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation - 6, // 35: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse - 3, // 36: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation - 27, // [27:37] is the sub-list for method output_type - 17, // [17:27] is the sub-list for method input_type - 17, // [17:17] is the sub-list for extension type_name - 17, // [17:17] is the sub-list for extension extendee - 0, // [0:17] is the sub-list for field type_name + 15, // 13: host.authority.v1.KeysResponse.admission_keys:type_name -> host.authority.v1.PublicKey + 8, // 14: host.authority.v1.RegisterAdmissionKeyRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 15, // 15: host.authority.v1.RegisterAdmissionKeyResponse.key:type_name -> host.authority.v1.PublicKey + 26, // 16: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant + 8, // 17: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 18: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 19: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 0, // 20: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest + 11, // 21: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest + 13, // 22: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest + 16, // 23: host.authority.v1.AuthorityService.RegisterAdmissionKey:input_type -> host.authority.v1.RegisterAdmissionKeyRequest + 18, // 24: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest + 20, // 25: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest + 22, // 26: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest + 24, // 27: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest + 2, // 28: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest + 5, // 29: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest + 4, // 30: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest + 10, // 31: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse + 12, // 32: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse + 14, // 33: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse + 17, // 34: host.authority.v1.AuthorityService.RegisterAdmissionKey:output_type -> host.authority.v1.RegisterAdmissionKeyResponse + 19, // 35: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse + 21, // 36: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse + 23, // 37: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse + 25, // 38: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse + 3, // 39: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation + 6, // 40: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse + 3, // 41: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation + 31, // [31:42] is the sub-list for method output_type + 20, // [20:31] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_host_authority_v1_authority_proto_init() } @@ -1963,7 +2117,7 @@ func file_host_authority_v1_authority_proto_init() { (*IssueRequest_ParentToken)(nil), (*IssueRequest_Delegation)(nil), } - file_host_authority_v1_authority_proto_msgTypes[24].OneofWrappers = []any{ + file_host_authority_v1_authority_proto_msgTypes[26].OneofWrappers = []any{ (*ResourceGrant_All)(nil), (*ResourceGrant_Id)(nil), (*ResourceGrant_Prefix)(nil), @@ -1975,7 +2129,7 @@ func file_host_authority_v1_authority_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_host_authority_v1_authority_proto_rawDesc), len(file_host_authority_v1_authority_proto_rawDesc)), NumEnums: 0, - NumMessages: 25, + NumMessages: 27, NumExtensions: 0, NumServices: 1, }, diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go index c2e63df5..02afed32 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go @@ -26,16 +26,17 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - AuthorityService_Issue_FullMethodName = "/host.authority.v1.AuthorityService/Issue" - AuthorityService_Check_FullMethodName = "/host.authority.v1.AuthorityService/Check" - AuthorityService_Keys_FullMethodName = "/host.authority.v1.AuthorityService/Keys" - AuthorityService_ListResources_FullMethodName = "/host.authority.v1.AuthorityService/ListResources" - AuthorityService_AttachResource_FullMethodName = "/host.authority.v1.AuthorityService/AttachResource" - AuthorityService_DetachResource_FullMethodName = "/host.authority.v1.AuthorityService/DetachResource" - AuthorityService_AuthorizeTransfer_FullMethodName = "/host.authority.v1.AuthorityService/AuthorizeTransfer" - AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" - AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" - AuthorityService_GetDelegation_FullMethodName = "/host.authority.v1.AuthorityService/GetDelegation" + AuthorityService_Issue_FullMethodName = "/host.authority.v1.AuthorityService/Issue" + AuthorityService_Check_FullMethodName = "/host.authority.v1.AuthorityService/Check" + AuthorityService_Keys_FullMethodName = "/host.authority.v1.AuthorityService/Keys" + AuthorityService_RegisterAdmissionKey_FullMethodName = "/host.authority.v1.AuthorityService/RegisterAdmissionKey" + AuthorityService_ListResources_FullMethodName = "/host.authority.v1.AuthorityService/ListResources" + AuthorityService_AttachResource_FullMethodName = "/host.authority.v1.AuthorityService/AttachResource" + AuthorityService_DetachResource_FullMethodName = "/host.authority.v1.AuthorityService/DetachResource" + AuthorityService_AuthorizeTransfer_FullMethodName = "/host.authority.v1.AuthorityService/AuthorizeTransfer" + AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" + AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" + AuthorityService_GetDelegation_FullMethodName = "/host.authority.v1.AuthorityService/GetDelegation" ) // AuthorityServiceClient is the client API for AuthorityService service. @@ -53,8 +54,21 @@ type AuthorityServiceClient interface { // Check verifies a presented Work Context for the audience the caller is, // re-checking the authorization revision and the seal live. Check(ctx context.Context, in *CheckRequest, opts ...grpc.CallOption) (*CheckResponse, error) - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + RegisterAdmissionKey(ctx context.Context, in *RegisterAdmissionKeyRequest, opts ...grpc.CallOption) (*RegisterAdmissionKeyResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -159,6 +173,16 @@ func (c *authorityServiceClient) Keys(ctx context.Context, in *KeysRequest, opts return out, nil } +func (c *authorityServiceClient) RegisterAdmissionKey(ctx context.Context, in *RegisterAdmissionKeyRequest, opts ...grpc.CallOption) (*RegisterAdmissionKeyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RegisterAdmissionKeyResponse) + err := c.cc.Invoke(ctx, AuthorityService_RegisterAdmissionKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *authorityServiceClient) ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListResourcesResponse) @@ -244,8 +268,21 @@ type AuthorityServiceServer interface { // Check verifies a presented Work Context for the audience the caller is, // re-checking the authorization revision and the seal live. Check(context.Context, *CheckRequest) (*CheckResponse, error) - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. Keys(context.Context, *KeysRequest) (*KeysResponse, error) + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + RegisterAdmissionKey(context.Context, *RegisterAdmissionKeyRequest) (*RegisterAdmissionKeyResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -329,6 +366,9 @@ func (UnimplementedAuthorityServiceServer) Check(context.Context, *CheckRequest) func (UnimplementedAuthorityServiceServer) Keys(context.Context, *KeysRequest) (*KeysResponse, error) { return nil, status.Error(codes.Unimplemented, "method Keys not implemented") } +func (UnimplementedAuthorityServiceServer) RegisterAdmissionKey(context.Context, *RegisterAdmissionKeyRequest) (*RegisterAdmissionKeyResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RegisterAdmissionKey not implemented") +} func (UnimplementedAuthorityServiceServer) ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListResources not implemented") } @@ -425,6 +465,24 @@ func _AuthorityService_Keys_Handler(srv interface{}, ctx context.Context, dec fu return interceptor(ctx, in, info, handler) } +func _AuthorityService_RegisterAdmissionKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterAdmissionKeyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorityServiceServer).RegisterAdmissionKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorityService_RegisterAdmissionKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorityServiceServer).RegisterAdmissionKey(ctx, req.(*RegisterAdmissionKeyRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AuthorityService_ListResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ListResourcesRequest) if err := dec(in); err != nil { @@ -570,6 +628,10 @@ var AuthorityService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Keys", Handler: _AuthorityService_Keys_Handler, }, + { + MethodName: "RegisterAdmissionKey", + Handler: _AuthorityService_RegisterAdmissionKey_Handler, + }, { MethodName: "ListResources", Handler: _AuthorityService_ListResources_Handler, diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go index c340058b..ebd0b27e 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go @@ -45,6 +45,9 @@ const ( AuthorityServiceCheckProcedure = "/host.authority.v1.AuthorityService/Check" // AuthorityServiceKeysProcedure is the fully-qualified name of the AuthorityService's Keys RPC. AuthorityServiceKeysProcedure = "/host.authority.v1.AuthorityService/Keys" + // AuthorityServiceRegisterAdmissionKeyProcedure is the fully-qualified name of the + // AuthorityService's RegisterAdmissionKey RPC. + AuthorityServiceRegisterAdmissionKeyProcedure = "/host.authority.v1.AuthorityService/RegisterAdmissionKey" // AuthorityServiceListResourcesProcedure is the fully-qualified name of the AuthorityService's // ListResources RPC. AuthorityServiceListResourcesProcedure = "/host.authority.v1.AuthorityService/ListResources" @@ -79,8 +82,21 @@ type AuthorityServiceClient interface { // Check verifies a presented Work Context for the audience the caller is, // re-checking the authorization revision and the seal live. Check(context.Context, *connect.Request[v1.CheckRequest]) (*connect.Response[v1.CheckResponse], error) - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. Keys(context.Context, *connect.Request[v1.KeysRequest]) (*connect.Response[v1.KeysResponse], error) + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + RegisterAdmissionKey(context.Context, *connect.Request[v1.RegisterAdmissionKeyRequest]) (*connect.Response[v1.RegisterAdmissionKeyResponse], error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -176,6 +192,12 @@ func NewAuthorityServiceClient(httpClient connect.HTTPClient, baseURL string, op connect.WithSchema(authorityServiceMethods.ByName("Keys")), connect.WithClientOptions(opts...), ), + registerAdmissionKey: connect.NewClient[v1.RegisterAdmissionKeyRequest, v1.RegisterAdmissionKeyResponse]( + httpClient, + baseURL+AuthorityServiceRegisterAdmissionKeyProcedure, + connect.WithSchema(authorityServiceMethods.ByName("RegisterAdmissionKey")), + connect.WithClientOptions(opts...), + ), listResources: connect.NewClient[v1.ListResourcesRequest, v1.ListResourcesResponse]( httpClient, baseURL+AuthorityServiceListResourcesProcedure, @@ -223,16 +245,17 @@ func NewAuthorityServiceClient(httpClient connect.HTTPClient, baseURL string, op // authorityServiceClient implements AuthorityServiceClient. type authorityServiceClient struct { - issue *connect.Client[v1.IssueRequest, v1.IssueResponse] - check *connect.Client[v1.CheckRequest, v1.CheckResponse] - keys *connect.Client[v1.KeysRequest, v1.KeysResponse] - listResources *connect.Client[v1.ListResourcesRequest, v1.ListResourcesResponse] - attachResource *connect.Client[v1.AttachResourceRequest, v1.AttachResourceResponse] - detachResource *connect.Client[v1.DetachResourceRequest, v1.DetachResourceResponse] - authorizeTransfer *connect.Client[v1.AuthorizeTransferRequest, v1.AuthorizeTransferResponse] - delegate *connect.Client[v1.DelegateRequest, v1.Delegation] - revokeDelegation *connect.Client[v1.RevokeDelegationRequest, v1.RevokeDelegationResponse] - getDelegation *connect.Client[v1.GetDelegationRequest, v1.Delegation] + issue *connect.Client[v1.IssueRequest, v1.IssueResponse] + check *connect.Client[v1.CheckRequest, v1.CheckResponse] + keys *connect.Client[v1.KeysRequest, v1.KeysResponse] + registerAdmissionKey *connect.Client[v1.RegisterAdmissionKeyRequest, v1.RegisterAdmissionKeyResponse] + listResources *connect.Client[v1.ListResourcesRequest, v1.ListResourcesResponse] + attachResource *connect.Client[v1.AttachResourceRequest, v1.AttachResourceResponse] + detachResource *connect.Client[v1.DetachResourceRequest, v1.DetachResourceResponse] + authorizeTransfer *connect.Client[v1.AuthorizeTransferRequest, v1.AuthorizeTransferResponse] + delegate *connect.Client[v1.DelegateRequest, v1.Delegation] + revokeDelegation *connect.Client[v1.RevokeDelegationRequest, v1.RevokeDelegationResponse] + getDelegation *connect.Client[v1.GetDelegationRequest, v1.Delegation] } // Issue calls host.authority.v1.AuthorityService.Issue. @@ -250,6 +273,11 @@ func (c *authorityServiceClient) Keys(ctx context.Context, req *connect.Request[ return c.keys.CallUnary(ctx, req) } +// RegisterAdmissionKey calls host.authority.v1.AuthorityService.RegisterAdmissionKey. +func (c *authorityServiceClient) RegisterAdmissionKey(ctx context.Context, req *connect.Request[v1.RegisterAdmissionKeyRequest]) (*connect.Response[v1.RegisterAdmissionKeyResponse], error) { + return c.registerAdmissionKey.CallUnary(ctx, req) +} + // ListResources calls host.authority.v1.AuthorityService.ListResources. func (c *authorityServiceClient) ListResources(ctx context.Context, req *connect.Request[v1.ListResourcesRequest]) (*connect.Response[v1.ListResourcesResponse], error) { return c.listResources.CallUnary(ctx, req) @@ -296,8 +324,21 @@ type AuthorityServiceHandler interface { // Check verifies a presented Work Context for the audience the caller is, // re-checking the authorization revision and the seal live. Check(context.Context, *connect.Request[v1.CheckRequest]) (*connect.Response[v1.CheckResponse], error) - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. Keys(context.Context, *connect.Request[v1.KeysRequest]) (*connect.Response[v1.KeysResponse], error) + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + RegisterAdmissionKey(context.Context, *connect.Request[v1.RegisterAdmissionKeyRequest]) (*connect.Response[v1.RegisterAdmissionKeyResponse], error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -389,6 +430,12 @@ func NewAuthorityServiceHandler(svc AuthorityServiceHandler, opts ...connect.Han connect.WithSchema(authorityServiceMethods.ByName("Keys")), connect.WithHandlerOptions(opts...), ) + authorityServiceRegisterAdmissionKeyHandler := connect.NewUnaryHandler( + AuthorityServiceRegisterAdmissionKeyProcedure, + svc.RegisterAdmissionKey, + connect.WithSchema(authorityServiceMethods.ByName("RegisterAdmissionKey")), + connect.WithHandlerOptions(opts...), + ) authorityServiceListResourcesHandler := connect.NewUnaryHandler( AuthorityServiceListResourcesProcedure, svc.ListResources, @@ -439,6 +486,8 @@ func NewAuthorityServiceHandler(svc AuthorityServiceHandler, opts ...connect.Han authorityServiceCheckHandler.ServeHTTP(w, r) case AuthorityServiceKeysProcedure: authorityServiceKeysHandler.ServeHTTP(w, r) + case AuthorityServiceRegisterAdmissionKeyProcedure: + authorityServiceRegisterAdmissionKeyHandler.ServeHTTP(w, r) case AuthorityServiceListResourcesProcedure: authorityServiceListResourcesHandler.ServeHTTP(w, r) case AuthorityServiceAttachResourceProcedure: @@ -474,6 +523,10 @@ func (UnimplementedAuthorityServiceHandler) Keys(context.Context, *connect.Reque return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.Keys is not implemented")) } +func (UnimplementedAuthorityServiceHandler) RegisterAdmissionKey(context.Context, *connect.Request[v1.RegisterAdmissionKeyRequest]) (*connect.Response[v1.RegisterAdmissionKeyResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.RegisterAdmissionKey is not implemented")) +} + func (UnimplementedAuthorityServiceHandler) ListResources(context.Context, *connect.Request[v1.ListResourcesRequest]) (*connect.Response[v1.ListResourcesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.ListResources is not implemented")) } diff --git a/modules/authority/services/local/code/plugins/authority/plugin.go b/modules/authority/services/local/code/plugins/authority/plugin.go index 12179578..d1e5af3c 100644 --- a/modules/authority/services/local/code/plugins/authority/plugin.go +++ b/modules/authority/services/local/code/plugins/authority/plugin.go @@ -1,4 +1,5 @@ -// Package authority serves codefly.dev/authority: Issue, Check, Keys. +// Package authority serves codefly.dev/authority: Issue, Check, Keys, and +// the hop's admission key registration. package authority import ( @@ -220,6 +221,16 @@ func (p *Plugin) Keys(context.Context, *authorityv1.KeysRequest) (*authorityv1.K return p.Issuer.Keys(), nil } +// RegisterAdmissionKey publishes the hop's admission key in Keys, for the hop +// alone, until its registration lapses. +func (p *Plugin) RegisterAdmissionKey(ctx context.Context, req *authorityv1.RegisterAdmissionKeyRequest) (*authorityv1.RegisterAdmissionKeyResponse, error) { + key, until, err := p.Issuer.RegisterAdmissionKey(ctx, req.GetActor().GetCredential(), req.GetPublicKey()) + if err != nil { + return nil, Status(err) + } + return &authorityv1.RegisterAdmissionKeyResponse{Key: key, ExpiresAtUnix: until.Unix()}, nil +} + // Status maps a refusal to a gRPC status that names its reason: every refusal // reaches the caller, none stays only in a log. The core's refusals carry // their code; this host's own and core's Work Context errors map by kind. diff --git a/modules/authority/services/local/code/plugins/registry_test/hop_admission_test.go b/modules/authority/services/local/code/plugins/registry_test/hop_admission_test.go new file mode 100644 index 00000000..3124bc7d --- /dev/null +++ b/modules/authority/services/local/code/plugins/registry_test/hop_admission_test.go @@ -0,0 +1,159 @@ +package registry_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "github.com/codefly-dev/core/resources" + codefly "github.com/codefly-dev/sdk-go" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + + "gateway/pkg/business/testkit" + authorityv1 "gateway/pkg/gen/host/authority/v1" + "gateway/plugins/authority" +) + +// Review r4 B1', under the playground's identity. The playground run starts +// the hop as authority/hop in workspace authority-playground with its own +// service-scoped hop-signing/SEED and its projected workload credential, and +// hands the local authority neither. The authority used to derive the key from +// that seed, so this run published no admission key and the console refused +// every call (keys_unavailable). Now the hop presents its public key over the +// wire under its workload credential, Keys publishes it, and a stamp the hop +// signs is admitted by the published key alone (the check every module's +// verifier makes first), while one signed by any other key is not. +// +// The module side's verifier (libraries/hop/go) links the authority-client +// bindings of the same protos, which cannot share a binary with this +// service's own, so it is driven from Keys in its own module +// (TestAVerifierThatReadBeforeTheHopRegisteredLearnsItsKeyWithinKeyRetry) and +// in the playground run; here the wire is this service's generated client. +func TestUnderThePlaygroundsIdentityTheHopsRegisteredKeyAdmitsItsStamps(t *testing.T) { + k := testkit.New(t) + client := authorityv1.NewAuthorityServiceClient(serveAuthority(t, k)) + ctx := context.Background() + + // The hop's process, as the playground run starts it. + seed := sha256.Sum256([]byte("the playground hop")) + projected := filepath.Join(t.TempDir(), "credential") + if err := os.WriteFile(projected, []byte(k.HopCredential(t)), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(resources.WorkspacePrefix, "authority-playground") + t.Setenv(resources.EnvironmentPrefix, "local") + t.Setenv(resources.ModulePrefix, "authority") + t.Setenv(resources.ServicePrefix, "hop") + t.Setenv(resources.ServiceSecretConfigurationPrefix+"__AUTHORITY__HOP__HOP_SIGNING__SEED", hex.EncodeToString(seed[:])) + delivered, err := codefly.For(ctx).Secret("hop-signing", "SEED") + if err != nil { + t.Fatalf("the hop reads its own seed under the playground's identity: %v", err) + } + signer, err := stamp.NewSigner(delivered) + if err != nil { + t.Fatal(err) + } + keys, err := client.Keys(ctx, &authorityv1.KeysRequest{}) + if err != nil || len(keys.GetAdmissionKeys()) != 0 { + t.Fatalf("before the hop registered Keys publishes %v: %v", keys.GetAdmissionKeys(), err) + } + + // The hop registers, presenting its public half and its credential. + credential, err := os.ReadFile(projected) + if err != nil { + t.Fatal(err) + } + id, public := signer.Admission() + register := func(credential string, public []byte) (*authorityv1.RegisterAdmissionKeyResponse, error) { + return client.RegisterAdmissionKey(ctx, &authorityv1.RegisterAdmissionKeyRequest{ + Actor: &authorityv1.WorkloadCredential{Credential: credential}, PublicKey: public}) + } + first, err := register(string(credential), public) + if err != nil || first.GetKey().GetKeyId() != id || first.GetExpiresAtUnix() <= time.Now().Unix() { + t.Fatalf("the hop's registration: %v %v", first, err) + } + // A restart registers again: still one key, renewed. + again, err := register(string(credential), public) + if err != nil || again.GetExpiresAtUnix() < first.GetExpiresAtUnix() { + t.Fatalf("the hop's re-registration: %v %v", again, err) + } + + keys, err = client.Keys(ctx, &authorityv1.KeysRequest{}) + if err != nil || len(keys.GetAdmissionKeys()) != 1 { + t.Fatalf("after the hop registered Keys publishes %v: %v", keys.GetAdmissionKeys(), err) + } + var published []string + for _, key := range keys.GetAdmissionKeys() { + if key.GetPurpose() != stamp.Purpose { + t.Fatalf("an admission key with purpose %q", key.GetPurpose()) + } + published = append(published, hex.EncodeToString(key.GetPublicKey())) + } + + issued, err := k.PersonIssue(t, "maya", "documents") + if err != nil { + t.Fatal(err) + } + // The run's clock: the testkit's starts at a fixed instant. + now := time.Unix(issued.GetContext().GetNotBeforeUnix(), 0) + stamped := func(by *stamp.Signer) metadata.MD { + proof, err := by.Sign(issued.GetToken(), "/test.Service/Read", "documents", now, issued.GetExpiresAtUnix()) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(issued.GetToken())) + return metadata.Pairs("x-codefly-work-context", issued.GetToken(), "x-codefly-hop-context", hex.EncodeToString(sum[:]), "x-codefly-hop-proof", proof) + } + md := stamped(signer) + key := stamp.SignedBy(md, published) + if key == "" { + t.Fatal("a genuine stamp is signed by no published key") + } + if _, err := stamp.Check(md, key, "/test.Service/Read", "documents", now); err != nil { + t.Fatalf("a genuine stamp under the published key: %v", err) + } + other := sha256.Sum256([]byte("not the hop")) + impostor, _ := stamp.NewSigner(hex.EncodeToString(other[:])) + if stamp.SignedBy(stamped(impostor), published) != "" { + t.Fatal("a stamp by an unregistered key is signed by a published one") + } + + // Another workload presenting its key is refused by name, and publishes nothing. + _, impostorKey := impostor.Admission() + if _, err := register(k.Credential(t, "documents"), impostorKey); status.Code(err) != codes.PermissionDenied || + !strings.Contains(err.Error(), "the caller is documents/documents, not the hop (authority/hop)") { + t.Fatalf("a module registered an admission key: %v", err) + } + if keys, _ := client.Keys(ctx, &authorityv1.KeysRequest{}); len(keys.GetAdmissionKeys()) != 1 { + t.Fatalf("a refused registration changed Keys: %v", keys.GetAdmissionKeys()) + } +} + +func serveAuthority(t *testing.T, k *testkit.Kit) *grpc.ClientConn { + t.Helper() + server := grpc.NewServer() + (&authority.Plugin{Issuer: k.Issuer}).RegisterGRPC(server) + listener := bufconn.Listen(1 << 20) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} diff --git a/modules/authority/services/local/code/work.go b/modules/authority/services/local/code/work.go index c8929322..ccdf091b 100644 --- a/modules/authority/services/local/code/work.go +++ b/modules/authority/services/local/code/work.go @@ -93,6 +93,8 @@ func configureLocalAuthority(ctx context.Context, _ *adapters.Configuration) (Cl if err != nil { return nil, err } + // Keys publishes the hop's admission key once the hop registers it + // (RegisterAdmissionKey): nothing about the hop is configured here. wiring.Set(st, issuer.New(st, issuerName, keyID, signing.Public().(ed25519.PublicKey))) // A delivery is verified against the policy the run wrote for its host, and // carried only by the module's own delivery workload, attested by the diff --git a/modules/authority/services/local/proto/host/authority/v1/authority.proto b/modules/authority/services/local/proto/host/authority/v1/authority.proto index 7b716dcc..104828ea 100644 --- a/modules/authority/services/local/proto/host/authority/v1/authority.proto +++ b/modules/authority/services/local/proto/host/authority/v1/authority.proto @@ -26,8 +26,21 @@ service AuthorityService { rpc Check(CheckRequest) returns (CheckResponse) { option (google.api.http) = {post: "/v1/authority:check" body: "*"}; } - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. rpc Keys(KeysRequest) returns (KeysResponse); + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + rpc RegisterAdmissionKey(RegisterAdmissionKeyRequest) returns (RegisterAdmissionKeyResponse); // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -260,8 +273,17 @@ message KeysRequest {} message KeysResponse { // The issuer every capability names. string issuer = 1; - // The public keys by key id. + // The public keys by key id: what a Work Context is signed with, and only + // that. Each carries purpose "work-context". repeated PublicKey keys = 2; + // The keys a hop signs each call's admission with, each with purpose + // "hop-admission": a module behind the hop verifies the admission by one of + // them. More than one is published while a hop's key is rotated, so the + // retiring and the new key overlap. They are never Work Context keys and are + // kept out of keys, so no verifier that reads keys alone can trust one to + // sign a Work Context. Empty until a hop registers its key + // (RegisterAdmissionKey). + repeated PublicKey admission_keys = 3; } // PublicKey is one verification key. @@ -272,6 +294,26 @@ message PublicKey { string algorithm = 2; // The raw public key bytes. bytes public_key = 3; + // What the key verifies: "work-context" (a Work Context) or "hop-admission" + // (a hop's signed call admission). A verifier uses a key only for its own + // purpose. + string purpose = 4; +} + +// RegisterAdmissionKeyRequest presents one hop admission key. +message RegisterAdmissionKeyRequest { + // The hop's workload credential; any other workload is refused. + WorkloadCredential actor = 1; + // The raw Ed25519 public key the hop signs admissions with: 32 bytes. + bytes public_key = 2; +} + +// RegisterAdmissionKeyResponse is the key as Keys now publishes it. +message RegisterAdmissionKeyResponse { + // The published entry, with its key id and purpose "hop-admission". + PublicKey key = 1; + // When the registration lapses unless the hop registers the key again. + int64 expires_at_unix = 2; } // ListResourcesRequest asks about one kind and one action. diff --git a/modules/authority/services/local/service.codefly.yaml b/modules/authority/services/local/service.codefly.yaml index ad2da266..c4899c82 100644 --- a/modules/authority/services/local/service.codefly.yaml +++ b/modules/authority/services/local/service.codefly.yaml @@ -16,6 +16,9 @@ agent: # stand-in modules); this module ships an empty default under # configurations/local, which a composition's own document replaces whole # (code/work.go applyComposition). +# Keys publishes the hop's admission key once the hop registers it over its +# workload credential (RegisterAdmissionKey): nothing about the hop is +# configured here, its seed never leaves it, and a run with no hop publishes none. workspace-configuration-dependencies: - federation - fixture-composition diff --git a/modules/console/module.codefly.yaml b/modules/console/module.codefly.yaml index e91b00d1..79f921b1 100644 --- a/modules/console/module.codefly.yaml +++ b/modules/console/module.codefly.yaml @@ -2,6 +2,9 @@ kind: module name: console services: - name: api + # The console's own Postgres, holding the hop admission reservations its + # verifier consumes. Private: the module interface does not export it. + - name: store # The console's API is what a person's page calls; exported so the composition # routes it and the package manifest names it. interface: diff --git a/modules/console/services/api/code/arrival_test.go b/modules/console/services/api/code/arrival_test.go new file mode 100644 index 00000000..d69d2c27 --- /dev/null +++ b/modules/console/services/api/code/arrival_test.go @@ -0,0 +1,222 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + hop "github.com/codefly-dev/authority/libraries/hop/go" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" + "github.com/codefly-dev/core/shared" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" + + "api/pkg/adapters" + gen "api/pkg/gen" +) + +const versionMethod = "/api.ApiService/Version" + +// counting is the console's API with its handler runs counted. +type counting struct { + gen.UnimplementedApiServiceServer + ran atomic.Int64 +} + +func (c *counting) Version(context.Context, *gen.VersionRequest) (*gen.VersionResponse, error) { + c.ran.Add(1) + return &gen.VersionResponse{Version: "0.1.0"}, nil +} + +// served is the console's generated server, both listeners, with its +// verifier installed exactly as answerFromTheStamp installs it, over keys +// that publish the hop's admission key and nothing configured beside them. +type served struct { + api *counting + grpc, connect string // listener addresses + md metadata.MD + retainedUntil time.Time // the admission's expiry plus the clock allowance +} + +func serve(t *testing.T) *served { + t.Helper() + t.Setenv("CODEFLY__ENVIRONMENT", "local") + authority := hoptest.New(t).PublishHop("console-hop") + token := authority.IssueSingleUse(t, "console") + s := &served{api: &counting{}, md: hoptest.Forward(t, token, "console-hop", versionMethod)} + // A one-second admission, so its window closes inside the test. + issued := time.Now() + proof, err := hoptest.Signer("console-hop").Sign(token, versionMethod, "console", issued, issued.Add(time.Second).Unix()) + if err != nil { + t.Fatal(err) + } + s.md.Set(hop.ProofKey, proof) + call, err := stamp.Check(s.md, hoptest.PublicKey("console-hop"), versionMethod, "console", issued) + if err != nil { + t.Fatal(err) + } + s.retainedUntil = time.Unix(call.Expires+int64(stamp.Skew/time.Second), 0) + + verifier, err := hop.NewVerifier(context.Background(), authority, "console", hoptest.Replay()) + if err != nil { + t.Fatal(err) + } + grpcPort, connectPort := freePort(t), freePort(t) + config := &adapters.Configuration{EndpointGrpcPort: grpcPort, EndpointConnectPort: shared.Pointer(connectPort)} + install(config, verifier) + config.Service = s.api + server, err := adapters.NewServer(config) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { _ = server.Start(ctx) }() + t.Cleanup(func() { cancel(); server.Stop() }) + s.grpc, s.connect = fmt.Sprintf("127.0.0.1:%d", grpcPort), fmt.Sprintf("127.0.0.1:%d", connectPort) + for _, address := range []string{s.grpc, s.connect} { + for deadline := time.Now().Add(5 * time.Second); ; { + if conn, err := net.Dial("tcp", address); err == nil { + _ = conn.Close() + break + } else if time.Now().After(deadline) { + t.Fatalf("%s never listened: %v", address, err) + } + time.Sleep(10 * time.Millisecond) + } + } + return s +} + +func freePort(t *testing.T) uint16 { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + return uint16(listener.Addr().(*net.TCPAddr).Port) +} + +// Review r3 M1: the hop routes the console's gRPC to its Connect listener, +// which hands each call to the gRPC server through ServeHTTP, where no tap +// handle runs, so a genuine call whose body arrived after the admission's +// window was refused as stamp_window. The verifier's stats handler judges the +// window when the call reaches the server, on that listener as on the gRPC +// one: a genuine call whose body arrives late is admitted, and its handler +// runs once. +func TestALateBodyIsJudgedAtArrivalOnBothListeners(t *testing.T) { + for _, listener := range []string{"grpc", "connect"} { + t.Run(listener, func(t *testing.T) { + s := serve(t) + address := s.grpc + if listener == "connect" { + address = s.connect + } + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + stream, err := conn.NewStream(metadata.NewOutgoingContext(ctx, s.md.Copy()), &grpc.StreamDesc{}, versionMethod) + if err != nil { + t.Fatal(err) + } + // The headers are sent now; the body only once the window has closed. + time.Sleep(time.Until(s.retainedUntil) + 300*time.Millisecond) + if err := stream.SendMsg(&gen.VersionRequest{}); err != nil { + t.Fatal(err) + } + if err := stream.CloseSend(); err != nil { + t.Fatal(err) + } + if err := stream.RecvMsg(&gen.VersionResponse{}); err != nil { + t.Fatalf("a genuine call whose body arrived late was refused: %v", err) + } + if got := s.api.ran.Load(); got != 1 { + t.Fatalf("the handler ran %d times", got) + } + }) + } +} + +// The same over the Connect protocol, as the hop's HTTP face forwards a +// Connect call: the unary body arrives after the window has closed. +func TestALateConnectBodyIsJudgedAtArrival(t *testing.T) { + s := serve(t) + body, _ := proto.Marshal(&gen.VersionRequest{}) + reader, writer := io.Pipe() + request, err := http.NewRequest(http.MethodPost, "http://"+s.connect+versionMethod, reader) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/proto") + request.Header.Set("Connect-Protocol-Version", "1") + for key, values := range s.md { + for _, value := range values { + request.Header.Add(key, value) + } + } + go func() { + time.Sleep(time.Until(s.retainedUntil) + 300*time.Millisecond) + _, _ = io.Copy(writer, bytes.NewReader(body)) + _ = writer.Close() + }() + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + answer, _ := io.ReadAll(response.Body) + if response.StatusCode != http.StatusOK || strings.Contains(string(answer), "stamp_") { + t.Fatalf("a genuine Connect call whose body arrived late was refused: %d %s", response.StatusCode, answer) + } + if got := s.api.ran.Load(); got != 1 { + t.Fatalf("the handler ran %d times", got) + } +} + +// One admission delivered on both listeners at once runs the handler once: +// every other delivery meets the reservation made when the first arrived. +func TestOneAdmissionOnBothListenersRunsOnce(t *testing.T) { + s := serve(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + var admitted atomic.Int64 + done := make(chan struct{}) + deliveries := 0 + for _, address := range []string{s.grpc, s.connect} { + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + for range 4 { + deliveries++ + go func() { + defer func() { done <- struct{}{} }() + var answer gen.VersionResponse + if conn.Invoke(metadata.NewOutgoingContext(ctx, s.md.Copy()), versionMethod, &gen.VersionRequest{}, &answer) == nil { + admitted.Add(1) + } + }() + } + } + for range deliveries { + <-done + } + if admitted.Load() != 1 || s.api.ran.Load() != 1 { + t.Fatalf("SECURITY: %d of %d deliveries admitted, handler ran %d times", admitted.Load(), deliveries, s.api.ran.Load()) + } +} diff --git a/modules/console/services/api/code/go.mod b/modules/console/services/api/code/go.mod index 2a373136..414b6f62 100644 --- a/modules/console/services/api/code/go.mod +++ b/modules/console/services/api/code/go.mod @@ -11,6 +11,7 @@ require ( github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/codefly-dev/sdk-go v0.4.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 + github.com/jackc/pgx/v5 v5.11.0 github.com/rs/cors v1.11.1 golang.org/x/net v0.60.0 google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b @@ -44,12 +45,16 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/term v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect diff --git a/modules/console/services/api/code/go.sum b/modules/console/services/api/code/go.sum index d6554232..c3682d41 100644 --- a/modules/console/services/api/code/go.sum +++ b/modules/console/services/api/code/go.sum @@ -46,6 +46,7 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -137,6 +138,18 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= @@ -162,10 +175,13 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= @@ -174,6 +190,9 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= @@ -208,6 +227,8 @@ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= @@ -224,10 +245,12 @@ google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= diff --git a/modules/console/services/api/code/store_test.go b/modules/console/services/api/code/store_test.go new file mode 100644 index 00000000..7a9e3e6f --- /dev/null +++ b/modules/console/services/api/code/store_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/codefly-dev/authority/libraries/hop/go/replaypg" +) + +// The console's store carries the table the adapter writes to. Two copies of +// one DDL drift, so this is the one that fails when they do: the migration is +// what the store runs, replaypg.Migration is what a module without a migration +// pipeline embeds, and they must stay the same table. +func TestTheStoreMigrationCarriesTheAdaptersTable(t *testing.T) { + const path = "../../store/migrations/1_hop_admissions.up.sql" + migration, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(migration), replaypg.Migration) { + t.Fatalf("%s no longer carries replaypg.Migration:\n%s", path, replaypg.Migration) + } + // The verifier reserves, reads back, ends a finished call's hold and + // collects. It updates active and nothing else, because a refreshed + // reservation would let a replay extend its own window. + for _, grant := range []string{ + "GRANT SELECT, INSERT, DELETE ON hop_admissions TO app_console;", + "GRANT UPDATE (active) ON hop_admissions TO app_console;", + } { + if !strings.Contains(string(migration), grant) { + t.Fatalf("%s does not grant exactly %q", path, grant) + } + } + if strings.Contains(string(migration), "UPDATE ON hop_admissions") { + t.Fatalf("%s grants UPDATE on every column of the reservations", path) + } +} diff --git a/modules/console/services/api/code/work.go b/modules/console/services/api/code/work.go index ebe90ba4..6dbaccd0 100644 --- a/modules/console/services/api/code/work.go +++ b/modules/console/services/api/code/work.go @@ -3,11 +3,16 @@ package main import ( "context" "fmt" + "strings" + "time" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" "github.com/codefly-dev/authority/libraries/authority-client/go/host" "github.com/codefly-dev/authority/libraries/hop/go" + "github.com/codefly-dev/authority/libraries/hop/go/replaypg" + "github.com/codefly-dev/core/wool" codefly "github.com/codefly-dev/sdk-go" + "github.com/jackc/pgx/v5/pgxpool" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -21,35 +26,107 @@ func init() { } // answerFromTheStamp is all the authority code the console carries: the hop's -// Verifier authenticates each forwarded Work Context against the authority's +// Verifier checks each call's admission by the hop keys the authority +// publishes, authenticates each forwarded Work Context against the authority's // published keys and reads who the call is for from it, and refuses, by name, // a call that did not come through the hop or carries a context the authority -// did not issue for the console. It re-checks no permission the hop decided. +// did not issue for the console. It re-checks no permission the hop decided, +// and nothing about the hop is configured here: its key comes with the rest. func answerFromTheStamp(ctx context.Context, config *adapters.Configuration) (Clean, error) { - proof, err := codefly.For(ctx).WorkspaceSecret("hop", "PROOF") - if err != nil || proof == "" { - return nil, fmt.Errorf("console: no run proof delivered (codefly config generate hop PROOF): %v", err) + replay, closeReplay, err := reservations(ctx) + if err != nil { + return nil, err } audience, err := hop.Audience() if err != nil { + closeReplay() return nil, fmt.Errorf("console: %w", err) } conn, _, err := host.Dial(ctx) if err != nil { + closeReplay() return nil, fmt.Errorf("console: %w", err) } // It starts whether or not the authority serves its keys yet, and refuses - // calls with the reason until it does. - verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, proof) + // calls with the reason until it does. Outside a local run it also refuses + // a replay store that is not the one every replica shares. + verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, replay) if err != nil { _ = conn.Close() + closeReplay() return nil, fmt.Errorf("console: %w", err) } + install(config, verifier) + config.Service = &console{} + return func() { _ = conn.Close(); closeReplay() }, nil +} + +// install puts the verifier in front of every call the console serves. Its +// stats handler judges the admission window and reserves the single-use nonce +// when each call reaches the gRPC server, before a request body is read, on +// both listeners: the gRPC one, and the Connect one the hop routes to, which +// hands each call to the same server. The interceptors hold the reservation +// until the handler returns. +func install(config *adapters.Configuration, verifier *hop.Verifier) { config.GRPCServerOptions = append(config.GRPCServerOptions, + grpc.StatsHandler(verifier.StatsHandler()), grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()), grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor())) - config.Service = &console{} - return func() { _ = conn.Close() }, nil +} + +// collectEvery is how often aged-out reservations are removed. Collection +// removes only a finished call's reservation past the admission's expiry plus +// the clock allowance, and the store refuses any reservation past that, so +// nothing it removes can be reserved again. +const collectEvery = time.Minute + +// reservations opens the console's hop admission reservations in its own store. +// The store is composed (services/store), so a run starts it first and delivers +// its read-write connection here. There is no in-memory fallback: a console +// that forgot an admission would admit a captured one again, so a composed +// store that does not answer is refused rather than fallen back from, and a run +// that composes no store is refused too. +func reservations(ctx context.Context) (*replaypg.Store, func(), error) { + w := wool.Get(ctx).In("console") + connection, err := codefly.For(ctx).Service("store").Secret("postgres", "read-write-connection") + if err != nil || strings.TrimSpace(connection) == "" { + return nil, nil, fmt.Errorf("console: the hop admission reservations need this module's store: %v", err) + } + pool, err := pgxpool.New(ctx, connection) + if err != nil { + return nil, nil, fmt.Errorf("console: the store's read-write connection: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, nil, fmt.Errorf("console: the store is composed but does not answer: %w", err) + } + store, err := replaypg.New(pool) + if err != nil { + pool.Close() + return nil, nil, fmt.Errorf("console: %w", err) + } + collecting, stop := context.WithCancel(context.Background()) + go collect(collecting, store, w) + w.Info("hop admissions are reserved in the console's store") + return store, func() { stop(); pool.Close() }, nil +} + +// collect removes aged-out reservations until the process ends. A failure is +// logged and retried: collection is housekeeping, and a console that cannot +// collect still refuses replays. +func collect(ctx context.Context, store *replaypg.Store, w *wool.Wool) { + ticker := time.NewTicker(collectEvery) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if _, err := store.Collect(ctx); err != nil && ctx.Err() == nil { + w.Warn("hop admission reservations were not collected", wool.ErrField(err)) + } + } + } } type console struct { diff --git a/modules/console/services/api/service.codefly.yaml b/modules/console/services/api/service.codefly.yaml index 4b2727c5..717b48d8 100644 --- a/modules/console/services/api/service.codefly.yaml +++ b/modules/console/services/api/service.codefly.yaml @@ -30,13 +30,16 @@ spec: rest-endpoint: false runtime-assets: [] # The console reaches the authority through the interface it depends on, never a -# service: the composition resolves which implementation answers. +# service: the composition resolves which implementation answers. Its own store +# it names directly: the hop admission reservations its verifier consumes must +# survive a restart and be shared by every replica, so a run starts the store +# first and delivers its read-write connection here (code/work.go reservations). service-dependencies: - interface: codefly.dev/authority@>=0.2.0 <0.4.0 + - name: store + module: console + endpoints: + - name: tcp # The console acts as a workload of its own: the run vouches for it and delivers # its manifest to the authority. module-identity: true -# The run's proof the hop writes on every stamp; the console believes a stamp -# only when it carries it. -workspace-configuration-dependencies: - - hop diff --git a/modules/console/services/store/.gitignore b/modules/console/services/store/.gitignore new file mode 100644 index 00000000..f1e3ec89 --- /dev/null +++ b/modules/console/services/store/.gitignore @@ -0,0 +1,17 @@ +# runtime-access.sql is re-rendered into the service directory on every +# `codefly build` — it is the bootstrap image's build context, generated and +# never hand-edited. Keep it out of version control. +/runtime-access.sql + +# bootstrap.sql is generated beside it and runs the migration under the +# runtime-access lock before including runtime-access.sql. +/bootstrap.sql + +# Postgres credentials. The `local` profile's postgres.secret.env is COMMITTED on +# purpose, as the obin modules' stores commit theirs: its values are throwaway, +# and a workspace composing this module resolves it into a read-only cache it +# cannot write a secret into, so the file travels with the module or the store +# never boots. The repository root ignores every *.secret.env under a +# configurations directory; exactly the local file is let back through here. +# Every other profile keeps its credentials in the provider's secret store. +!/configurations/local/postgres.secret.env diff --git a/modules/console/services/store/README.md b/modules/console/services/store/README.md new file mode 100644 index 00000000..23103c4d --- /dev/null +++ b/modules/console/services/store/README.md @@ -0,0 +1,34 @@ +# store — the console's hop admission reservations + +One table, `hop_admissions`, holding the nonce of every call the hop admitted +to the console. The console's verifier reserves a nonce when the call arrives, +before its body is read, and holds it until the handler has returned, so a +captured admission is refused the second time it is presented. + +It is a store and not a set in the process because the guarantee has to hold +across every replica of the console and across a restart. The admission +contract asked for "a durable shared replay volume" instead, which Codefly does +not deliver: in practice that meant an absolute path to per-pod storage, which +looks identical to a shared mount and silently becomes a per-replica store, +letting a captured admission replay once per replica inside its window +(security review r164, B1). A module that has a store has somewhere atomic to +put a nonce, so the console composes one. + +`INSERT ... ON CONFLICT (nonce) DO NOTHING` is the reservation: Postgres decides +the one winner among concurrent inserts, whichever replica they reach. +`libraries/hop/go/replaypg` is the adapter, and `replaypg.Migration` is the +table this service's migration carries; `code/store_test.go` asserts the two +have not drifted. + +Reservations are retained to the admission's expiry plus the clock allowance, +and for as long as the call holding one runs (`active`), so the table does not +grow without bound: the console runs `replaypg.Store.Collect` on a schedule, and +it removes only finished reservations past their retention. Removing one is +safe because a reservation is refused, on the database's clock, once its +retention has passed, so a later delivery of a collected nonce can never +reserve it again. A console that stops while a call runs leaves that one row +held; nothing reopens admission. The service may update `active` and no other +column. + +Its one reader is this module's `api`. The endpoint is private and the module +interface does not export it. diff --git a/modules/console/services/store/configurations/local/README.md b/modules/console/services/store/configurations/local/README.md new file mode 100644 index 00000000..2f7cf433 --- /dev/null +++ b/modules/console/services/store/configurations/local/README.md @@ -0,0 +1,29 @@ +# store — local configuration + +`postgres.secret.env` here is **committed on purpose**. It holds the throwaway +credentials the `local` profile boots the `store` Postgres service with: an +owner login and two role passwords that protect nothing and must never be +reused anywhere. + +It is committed because a composing workspace cannot supply it: Codefly reads a +service's configuration from the service's own directory, and a workspace that +composes this module by tag resolves it into a read-only module cache that is +the published tree byte for byte. The repository root ignores every +`*.secret.env` under a configurations directory; this service's `.gitignore` +lets exactly this file back through. + +The file also carries the **key names** a render needs: `codefly deploy gitops +render` promotes each key into a typed Kubernetes Secret reference, and the +postgres agent refuses to render without one for each. Only the names reach a +rendered manifest; the values never do. **No hosted profile ever ships one**: +every other profile's credentials live in the provider's secret store, +generated there. + +The keys the postgres agent reads: + +- `POSTGRES_USER` — the owner / migrator role +- `POSTGRES_PASSWORD` — owner password +- `POSTGRES_READ_ONLY_PASSWORD` — read-only principal password +- `POSTGRES_READ_WRITE_PASSWORD` — read-write principal password; the agent + exports its connection as the `postgres/read-write-connection` secret the + local authority reads diff --git a/modules/console/services/store/configurations/local/postgres.secret.env b/modules/console/services/store/configurations/local/postgres.secret.env new file mode 100644 index 00000000..2bf598a0 --- /dev/null +++ b/modules/console/services/store/configurations/local/postgres.secret.env @@ -0,0 +1,4 @@ +POSTGRES_USER=user +POSTGRES_PASSWORD=local-owner-password-change-me +POSTGRES_READ_ONLY_PASSWORD=local-read-only-password-change-me +POSTGRES_READ_WRITE_PASSWORD=local-read-write-password-change-me diff --git a/modules/console/services/store/migrations/1_hop_admissions.down.sql b/modules/console/services/store/migrations/1_hop_admissions.down.sql new file mode 100644 index 00000000..82dfc4be --- /dev/null +++ b/modules/console/services/store/migrations/1_hop_admissions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS hop_admissions; diff --git a/modules/console/services/store/migrations/1_hop_admissions.up.sql b/modules/console/services/store/migrations/1_hop_admissions.up.sql new file mode 100644 index 00000000..58f37b57 --- /dev/null +++ b/modules/console/services/store/migrations/1_hop_admissions.up.sql @@ -0,0 +1,42 @@ +-- Hop admission reservations: one row per admitted call, keyed by the nonce the +-- hop signed into its admission. The verifier consumes a nonce when the call +-- arrives, before its body is read, and holds it while the handler runs, so a +-- captured admission is refused the second time it is presented. This has to +-- hold across every replica of the console and across a restart, which is why +-- it is a table and not a set in the process. +-- +-- The runtime read-write role first: service.codefly.yaml declares +-- `runtime-read-write-roles: [app_console]`, the postgres agent reconciles its +-- managed read-write principal into it on every boot and refuses to boot while +-- the role is absent. Idempotent: created only when absent. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_console') THEN + CREATE ROLE app_console NOINHERIT; + END IF; +END $$; + +GRANT USAGE ON SCHEMA public TO app_console; + +-- The migration owner may SET ROLE app_console, so a test runs under exactly +-- the privileges the console has. +DO $$ +BEGIN + EXECUTE format('GRANT app_console TO %I', current_user); +END $$; + +-- The table is exactly libraries/hop/go/replaypg.Migration; the console's test +-- asserts this file still carries it, so the two cannot drift. The primary key +-- is the reservation: Postgres decides one winner among concurrent inserts. +CREATE TABLE IF NOT EXISTS hop_admissions ( + nonce TEXT PRIMARY KEY, + retain_until TIMESTAMPTZ NOT NULL, + active BOOLEAN NOT NULL DEFAULT FALSE +); +CREATE INDEX IF NOT EXISTS hop_admissions_retain_until ON hop_admissions (retain_until); + +-- Exactly what the verifier does: reserve, read back, end a finished call's +-- hold, and collect what has aged out. The only UPDATE is of active: a +-- reservation's retention is never refreshed. +GRANT SELECT, INSERT, DELETE ON hop_admissions TO app_console; +GRANT UPDATE (active) ON hop_admissions TO app_console; diff --git a/modules/console/services/store/service.codefly.yaml b/modules/console/services/store/service.codefly.yaml new file mode 100644 index 00000000..227394a1 --- /dev/null +++ b/modules/console/services/store/service.codefly.yaml @@ -0,0 +1,49 @@ +name: store +version: 0.0.0 +description: | + The console's own Postgres, holding one thing: the hop admission reservations + its verifier consumes. An admission is single-use, and that has to hold across + every replica of the console and across a restart, so the reservation cannot + live in the process. It used to be asked of a "shared replay volume" the + platform does not deliver, which in practice meant a per-pod directory and a + captured admission replaying once per replica. A row here is the reservation, + and Postgres decides the one winner among concurrent inserts. +agent: + kind: codefly:service + name: postgres + version: 0.0.144 + publisher: codefly.dev +endpoints: + - name: tcp + # Only this module's api reaches the store, and the module interface does + # not export it. In-system (no `location`): the run allocates its address. + visibility: private +spec: + auth-mode: "" + database-name: console + docker-image: "" + docker-image-platforms: [] + extensions: [] + external-instances: {} + hot-reload: true + keep-running: false + log-level: "" + migration-sources: [] + no-migration: false + runtime-logins: [] + # The agent reconciles its managed read-write principal into this role on + # every boot; migrations/1_hop_admissions.up.sql creates it and grants + # exactly what the verifier does: reserve a nonce, read it back, and remove + # one whose retention has passed. Never update: letting a second insert + # refresh a reservation would let a replay extend its own window. + runtime-read-write-roles: + - app_console + runtime-schemas: [] + timeouts: + bootstrap-job-seconds: 0 + bootstrap-readiness-seconds: 0 + connect-seconds: 0 + migration-lock-seconds: 0 + migration-statement-seconds: 0 + readiness-seconds: 0 + without-ssl: false diff --git a/playground/configurations/README.md b/playground/configurations/README.md index 386e9b88..881deb90 100644 --- a/playground/configurations/README.md +++ b/playground/configurations/README.md @@ -6,9 +6,24 @@ the repository root, because a module's checkout root provides its groups to every workspace that composes it. The authority module provides none, so a workspace composing it owns its edge and hop groups whole. -The run's proof and internal token are generated, never committed: +The hop's service-only signing seed and internal token are generated, never committed: ```bash -codefly config generate hop PROOF +codefly config generate hop-signing SEED --service authority/hop codefly config generate internal-auth CODEFLY_INTERNAL_TOKEN ``` + +Nothing else about the hop is configured here. The hop registers the public +half of that seed with the local authority under its own workload credential, +and the authority publishes it in `Keys` (`admission_keys`), which every module +behind the hop already reads, so no verification key is written by hand ([hop admission migration](../../libraries/hop/README.md), "Delivering the +verification key"). + +No replay volume is configured here any more. Admission reservations live in the +console's own Postgres (`modules/console/services/store`), because Codefly +delivers a store and does not deliver a shared volume; an absolute path to +per-pod storage looks identical to a shared mount and silently becomes a +per-replica store. Outside a local run the verifier now refuses a store that +does not declare itself shared. + +The old shared PROOF and warning mode cannot authorize calls. diff --git a/tools/noproxygate/generated.json b/tools/noproxygate/generated.json index 202d32fa..485efeb0 100644 --- a/tools/noproxygate/generated.json +++ b/tools/noproxygate/generated.json @@ -3,8 +3,8 @@ "libraries/authority-client/go/gen/api_grpc.pb.go": "71617cc6107e9332fa88dc1889892378cf36e0887a5d05b87e7af8089a158f5a", "libraries/authority-client/go/gen/host/artifact/v1/artifact.pb.go": "8f032d82c2a6921e47ec08cde4c797fa75ec05e6c7cccb5b5a1be744805e8f4e", "libraries/authority-client/go/gen/host/artifact/v1/artifact_grpc.pb.go": "d30b417db566534c3cb3e8a0ce4410ba0660698a8dcbeb83b36ac2fb726810e0", - "libraries/authority-client/go/gen/host/authority/v1/authority.pb.go": "e9e35ce1a1b3e5d627b33b44653ae1d7913d684dfe12d512b2d8fd6526dd294a", - "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "bfabf589bea73aaa909d7889ccb05dfbd7f5001482a3c68aafeec70383a66577", + "libraries/authority-client/go/gen/host/authority/v1/authority.pb.go": "c1624bcd92bee470ee22ad4a868f4fc2ba53fc5d5250fec4b7c3f9c0d076da1e", + "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "4e0519a8f4d9d337b67f71cece2ab68978ca3ba63a1b840cb7d13dcd3aceb080", "libraries/authority-client/go/gen/host/authority/v1/work_context.pb.go": "c53b6b47355ee9a502441173931b3b809e510054168aa0cc2984b5152e771bf1", "libraries/authority-client/go/gen/host/composition/v1/composition.pb.go": "8fc4657b7b207b2fd80db74b238b10e5b0afd65b25851d56817cb0b960b1c750", "libraries/authority-client/go/gen/host/composition/v1/composition_grpc.pb.go": "acd95033d684741c23c74eb9f05021ebe487b9ab9e9cd81d549d44ab6c6d6162", @@ -39,10 +39,10 @@ "modules/authority/services/local/code/pkg/gen/host/artifact/v1/artifact.pb.go": "13863e4c49e9b038ea946ac6c2a373dc222f5b1fb8ddc437109c9bd2afa7bb3f", "modules/authority/services/local/code/pkg/gen/host/artifact/v1/artifact_grpc.pb.go": "d30b417db566534c3cb3e8a0ce4410ba0660698a8dcbeb83b36ac2fb726810e0", "modules/authority/services/local/code/pkg/gen/host/artifact/v1/artifactv1connect/artifact.connect.go": "8d5ab439f1293c76d3b5d2fe0e2a4845694a08b007f13c62f19c2de6c6e2b20f", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go": "8394375d99a2fcc535ff76821f0793897d17356d089c8463d28e5043ce70cf73", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go": "cb634089e403795182aec0c193a8f16decf894cf98cab020712b86f8a3fb1696", "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.gw.go": "37a1508e28e8bdb5310471401c9f78a5b14faa646e826bc5592ef64684fb7ea2", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go": "bfabf589bea73aaa909d7889ccb05dfbd7f5001482a3c68aafeec70383a66577", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go": "988913b6a4466f9143731dedc62782c468769d156c7cc5aa9fa6062efaee0f87", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go": "4e0519a8f4d9d337b67f71cece2ab68978ca3ba63a1b840cb7d13dcd3aceb080", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go": "8a76ce6fa7434f15a98e1375cbd058468e842864bfb94c9873d997bbd438c470", "modules/authority/services/local/code/pkg/gen/host/authority/v1/work_context.pb.go": "30da6150c9d3567ba0552590dd4ae3736f313097d4f4df8fcbaf242fca8af029", "modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go": "c7a1220dc4bfeb3419485a4b41b5a8a5f8a9a35654180402b46010345f5094e4", "modules/authority/services/local/code/pkg/gen/host/composition/v1/composition_grpc.pb.go": "acd95033d684741c23c74eb9f05021ebe487b9ab9e9cd81d549d44ab6c6d6162",