From dc49cde52d89037ab5c0a9c57d099adff301faa1 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:48:38 -0400 Subject: [PATCH 1/5] feat(authority): a hop registration proves it holds the key (authority 0.3.4) Review of codefly-dev/authority#28 (round e, F1): RegisterAdmissionKey was authorized by the hop's 600 s bearer workload credential alone, so whoever captured that credential could register any key for its lifetime and forge admissions. RegisterAdmissionKeyRequest gains proof (field 3): an Ed25519 signature by the admission key itself over a domain-separated array of the authority's audience, the SHA-256 of the presented credential, the public key, a fresh 32-byte nonce and the signing second. stamp.ProveRegistration signs it and stamp.CheckRegistration verifies it (30 s window, Skew ahead); the authority reserves the nonce. Additive on the wire: consumers' >=0.2.0 <0.4.0 admits 0.3.4. Python and TypeScript only read Keys, so they gain the field and no code. Regenerated only through codefly v0.1.215 (generate proto in the local service, generate client --no-facade for authority-client; both reproduced main byte for byte before the proto edit). The no-proxy gate's manifest records the five regenerated Go files. Co-Authored-By: Claude Opus 5.5 --- interfaces/authority/interface.codefly.yaml | 2 +- .../contract/catalog.codefly.json | 2 +- .../authority-client/contract/contract.binpb | Bin 397951 -> 398919 bytes .../go/gen/host/authority/v1/authority.pb.go | 23 +++- .../host/authority/v1/authority_grpc.pb.go | 20 ++- .../authority-client/go/stamp/registration.go | 115 ++++++++++++++++++ .../go/stamp/registration_test.go | 75 ++++++++++++ .../authority-client/library.codefly.yaml | 2 +- .../_gen/host/authority/v1/authority_pb2.py | 50 ++++---- .../host/authority/v1/authority_pb2_grpc.py | 10 +- .../src/gen/host/authority/v1/authority_pb.ts | 26 +++- modules/authority/module.codefly.yaml | 2 +- .../pkg/gen/host/authority/v1/authority.pb.go | 23 +++- .../host/authority/v1/authority_grpc.pb.go | 20 ++- .../authorityv1connect/authority.connect.go | 20 ++- .../proto/host/authority/v1/authority.proto | 19 ++- tools/noproxygate/generated.json | 10 +- 17 files changed, 351 insertions(+), 68 deletions(-) create mode 100644 libraries/authority-client/go/stamp/registration.go create mode 100644 libraries/authority-client/go/stamp/registration_test.go diff --git a/interfaces/authority/interface.codefly.yaml b/interfaces/authority/interface.codefly.yaml index f6c4ccd4..727c09a0 100644 --- a/interfaces/authority/interface.codefly.yaml +++ b/interfaces/authority/interface.codefly.yaml @@ -2,7 +2,7 @@ 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.3 +version: 0.3.4 type: connect protobuf: package: host.authority.v1 diff --git a/libraries/authority-client/contract/catalog.codefly.json b/libraries/authority-client/contract/catalog.codefly.json index 749f8d97..248e8308 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:1c51ec903d141b1df6c0eff0fcaa10aa48b6633e0753a13718bef83d086dc0cb", + "digest": "sha256:3f768e844a891e1554b597e586afb39720a2bd6cdb89d99da803e2d54db4204b", "services": [ { "name": "LocalService", diff --git a/libraries/authority-client/contract/contract.binpb b/libraries/authority-client/contract/contract.binpb index 64bdfd23ca4138b3e215d0400ed63df111e95e0d..98313b30bf9da25e464669bb61a1fc9cd5de6677 100644 GIT binary patch delta 6521 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 S5pr8$D1pq)InGP delta 5520 zcmYjVYj9Q789jTSy-)5vH;`nL7lAzF(Q4uiYLJJR^6(Lqx8MVnw^2k)5o82(gqV;_ z5(G(zyQ2+?JQYwNlsgEOK}w;cfVGrH>mYTQG%^$!T7@EF*SF8v(0_M*_gia!dwu(L z@1ZT(Kd;MPQq8J)wOcKA%7^3p@+XOjaoHv6F7&%;)?a4ontpOx;-`M{KT-d?b<9sv z(sABO{5V7&VC-vM))JL$g~yi)e(Q zll-;Zj3<=aC!t2j-mIvP*CosmVDw49(IPPVlnnRVlMO>Ew=6Mzq@3v$m3ir5js&AD zqtGHS%5ERw{~V0rDR)%j`v+ttD;jl;;ej#g8p8u)6d0R)Ge(kfrzX}+kcF~ns+Se2 zCV??E`xcA9m^x^Jzmge7M7cBc4-;im;S8^Hm=XbDMoOMVAk4U_#DCD_N0nQyTOXAV z7nOUNVMY}BrlE*vE* zoZslxkINCM6_Ip*fG>m-L39qU)c<{44#=Go@o6OCa0dp>6KQ@ura}-%kDer#70y%H zHU-l2x^)i`17W_%^zSnH3_y#?@}8pkS@|{t`SW}93=v2E0@1}^9}Iw^7wG3F%Z-H# z(hF<~2n%}l3XzK@FA~20m>B?HEz2$XP7DgsJW}%cZRR8$u|h5><>x{Cfimnr3rAsbc9FA@m@;lyTpm zWGEPQss3V`oO0(<7R$6bjB>JaErh~SR$vEB!Z5XrC;4NHLK2XEe!3hNuVs-gAtecv zT9%m=!U!n!ROcc?i2&&f)8$LW^(<;fiGWgneUvCDD~Qq(_z?w6{C0-SXT>X6G&>ZI zg0zC6tAU9=rqjrHy1yo9lMkrBeL}7+Ze$rbHVK?YmYW~K7zWZb(A&X4F~G!!<+2CM z*DR`R7D$?9WcejnnM`?aGH12^@kx1G-kU5Ec+0p0sTPtd#WVqd^oXbA;DQzw4ZKAV zLM^Ooj=z~fi7W3N=De%ldrH2S_YR8$hH;~`j+AbN5}*?6^xT=UB)*PCQ%p$+LTMdS zf#sYtG$a6fATg)Ueh10SLjZ zfc6O+H-(PY|~q2$w~2TwtfV`*+%sT z!(o?-9YpDADEM-R?)QxBy?BT9*`)cLc5$oq8@VXB)%pzx zrB>^=%W3!Sygt_e1#77U--5gIdf^h>o!1NZkiCm>&mV3I$FRF}r`huUf?X`BQ&(vD`d=ENYO-`R|R_X5Sgv3DrE7@^kIf0`D-#PNqI+^^Jo22g*;h)ly-T{cSRBoqGODAGI~i-=pAFR zWHT5L^!^`W2^wgKIE+4HoUS7wl6(+9W4H^B&`=TOonX!vddM94Y2FDI33fxo_|itc z(2fLDqD>b*haK6*qQSmL5Jk7qQ9B$$QRRKboUe7ubF%D)uk3jeH9{vzs279)l>o}s z3(v~j-he3kq&-vtp?H$^)i@~n%KL^n-|Dita%|o=_N?=b;%QPG3`IaCPV3*zl|$pF zS+ujU13?I#W@*8(;G4bOP8Y|`hJr!cbJ*?Nnu+678i-jZ zEAJKV)a!pPkOT5w;o*gpY!-hthkZ92jKHhpU(@!OycNH_YEFUr2fjXb;w z5rlRl4^QaM<|b_BIH9Yk9$+P!_2C!g($Z!g3)TaI6y+IR%^UzB(agICH%(_w® zI6_ST229kyB#*J;mg^d%+`=;gACLr%7XFK0qEs$Yl(&XETXgNq^47dHJQ6&|Qp|kT z(tL^`45-9fed1+#OMI=J4}uU{Yv+?N=KqWTnrp2!q5{m5ThUosKj>Nqf(BEZ|C823_(b4 z=eQtfF{PTxwo>Ke4F!{J)vGJzgS}d9<;Vf0l`5wLDouF_?(9VscbO`>>rK_DBEciQ zjAWXrVmIe8qzeJ4#BTk4wHzPc&69)egdik$Tg%f-6?>=(dQ1T-u}9BeDj&+{>+PH(J{kH!!1@KKK-Cs8^=l2Z=(P7Ep$blJIN!#%Fi(KJVkQ!y9!W=Q<^Q4%@ZJ$sl3zN z`ImlfnH-;Y+8UN=Ja4zN2c&xzv%jwdpHWx4^#BlZ?bd@#v+_Eq0$LM*blXmTRSt@G z*a{E?rGqMS!*7k5@*T0J9?e9wjoGS zvh5+vHcNTux$|%A^Wgix@QZr*w8VwQ370_l##8~~NLpsy^KqkCSk zb3hQf7ibQTKsQ@?KX9koWjXijHI4XVsB(oIoc{w4Uy#{G@e(Q0AqJ?#C9c0-AxFnA z*#(FwBrn+om~9r|W!nNkB`yaoz-I!s1%M!4wk^mueqOPD0xEIE_}LeqLA+x91O)Ml z^)owoHdsFa@oM0D=t?;-e$^U^s0qVRYF;+I$3>-!nawsc!#=Lm8&}GTc%>U28^{5r z(hZM|Z1bC?iYRv&3IwY3uts@jew7<8QsjVA<-%Tip?6hYt?Mk)Esb(SUacDm{zd3& zT&*J^T5EvxX7HM@*6Q47@EZt02-UgS!5!VzEYAj_^fwgP(V#1uAJ=UU}&v$Bf$@+98+79D24@ggrj#W74o86d|rP`WZ{2n9sb4;r> zQOE&6{K24yG|NH7+KpYy0Y$svIiF*i{0320(H#w}#2fnEX8C&Y8*VIkW+O;ZH+%}@ km?p0#*BS#3y1ZIH`?_3}y4sBfKQxd8&gw*(mi-q04@$fEaR2}S 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 16f2a4ae..9a982bbc 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 @@ -1130,7 +1130,16 @@ type RegisterAdmissionKeyRequest struct { // 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"` + PublicKey []byte `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + // Proof that the caller holds public_key's private half: a compact + // "." in unpadded base64url, the signature an Ed25519 + // signature by public_key over the payload, a JSON array of the domain + // "codefly-hop-registration-v1", the authority's audience, the hex SHA-256 + // of actor's credential, the hex public key, a fresh 32-byte hex nonce and + // the Unix second it was signed. The authority refuses a proof that does + // not verify, names another credential, key or audience, is older than 30 + // seconds or ahead of its clock, or repeats a nonce it has seen. + Proof string `protobuf:"bytes,3,opt,name=proof,proto3" json:"proof,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1179,6 +1188,13 @@ func (x *RegisterAdmissionKeyRequest) GetPublicKey() []byte { return nil } +func (x *RegisterAdmissionKeyRequest) GetProof() string { + if x != nil { + return x.Proof + } + return "" +} + // RegisterAdmissionKeyResponse is the key as Keys now publishes it. type RegisterAdmissionKeyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1948,11 +1964,12 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n" + "\n" + "public_key\x18\x03 \x01(\fR\tpublicKey\x12\x18\n" + - "\apurpose\x18\x04 \x01(\tR\apurpose\"y\n" + + "\apurpose\x18\x04 \x01(\tR\apurpose\"\x8f\x01\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" + + "public_key\x18\x02 \x01(\fR\tpublicKey\x12\x14\n" + + "\x05proof\x18\x03 \x01(\tR\x05proof\"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" + 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 02afed32..ba36f128 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 @@ -60,9 +60,13 @@ type AuthorityServiceClient interface { 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 + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. The + // hop also proves it holds the key: the request carries a fresh proof signed + // by the admission key itself over the credential, the key and the + // authority's audience, and a stale, replayed or unsigned registration is + // refused, so a captured credential alone registers nothing. 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 @@ -274,9 +278,13 @@ type AuthorityServiceServer interface { 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 + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. The + // hop also proves it holds the key: the request carries a fresh proof signed + // by the admission key itself over the credential, the key and the + // authority's audience, and a stale, replayed or unsigned registration is + // refused, so a captured credential alone registers nothing. 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 diff --git a/libraries/authority-client/go/stamp/registration.go b/libraries/authority-client/go/stamp/registration.go new file mode 100644 index 00000000..acf2b490 --- /dev/null +++ b/libraries/authority-client/go/stamp/registration.go @@ -0,0 +1,115 @@ +package stamp + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" +) + +// RegistrationWindow is how long a registration proof stays fresh: an +// authority refuses one signed longer ago than this, or more than Skew ahead +// of its clock, and remembers each nonce it accepted for this window plus Skew. +const RegistrationWindow = 30 * time.Second + +// registrationDomain separates a registration proof from a call admission +// ("codefly-hop-v1") and from a Work Context: a signature for one is never +// read as the other. +const registrationDomain = "codefly-hop-registration-v1" + +// Registration is a checked registration proof: who it binds and when it was +// signed. The nonce is what an authority reserves so the proof is used once. +type Registration struct { + Nonce string + Issued int64 +} + +// ProveRegistration is the hop's proof that it holds the admission key it +// registers: the admission key itself signs the authority's audience, the +// digest of the workload credential presented with it, the public key and a +// fresh nonce and time. A captured credential alone therefore registers +// nothing, and a proof made for one credential, key or authority is refused +// with any other. +func (s *Signer) ProveRegistration(credential, audience string, now time.Time) (string, error) { + if s == nil || len(s.key) != ed25519.PrivateKeySize || credential == "" || audience == "" { + return "", errors.New("hop: a registration proof needs the admission key, the workload credential and the authority's audience") + } + nonce := make([]byte, 32) + if _, err := rand.Read(nonce); err != nil { + return "", errors.New("hop: cannot generate a registration nonce") + } + _, public := s.Admission() + payload, err := registrationPayload(audience, credential, public, hex.EncodeToString(nonce), now.Unix()) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(s.key, payload)), nil +} + +func registrationPayload(audience, credential string, public []byte, nonce string, issued int64) ([]byte, error) { + digest := sha256.Sum256([]byte(credential)) + return json.Marshal([]any{registrationDomain, audience, hex.EncodeToString(digest[:]), hex.EncodeToString(public), nonce, issued}) +} + +// CheckRegistration verifies proof as the authority at audience: signed by +// public over exactly this credential, this key and this audience, within +// RegistrationWindow of now. It does not remember the nonce; the caller +// reserves it, once every other check has passed. +func CheckRegistration(proof string, public ed25519.PublicKey, credential, audience string, now time.Time) (Registration, error) { + refuse := func(why string) (Registration, error) { + return Registration{}, errors.New("the registration proof " + why) + } + if len(proof) > 4096 { + return refuse("is too long") + } + parts := strings.Split(proof, ".") + if len(parts) != 2 { + return refuse("is missing or not .: the hop proves it holds the key it registers") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil || base64.RawURLEncoding.EncodeToString(payload) != parts[0] { + return refuse("payload is not canonical base64url") + } + signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || base64.RawURLEncoding.EncodeToString(signature) != parts[1] { + return refuse("signature is not canonical base64url") + } + if len(public) != ed25519.PublicKeySize || !ed25519.Verify(public, payload, signature) { + return refuse("is not signed by the key being registered") + } + var fields []json.RawMessage + if json.Unmarshal(payload, &fields) != nil || len(fields) != 6 { + return refuse("payload is not the registration array") + } + var domain, signedAudience, digest, key string + var got Registration + for n, dst := range []any{&domain, &signedAudience, &digest, &key, &got.Nonce, &got.Issued} { + if string(fields[n]) == "null" || json.Unmarshal(fields[n], dst) != nil { + return refuse("payload is not the registration array") + } + } + want := sha256.Sum256([]byte(credential)) + nonce, err := hex.DecodeString(got.Nonce) + switch { + case domain != registrationDomain: + return refuse("is not a registration proof") + case audience == "" || signedAudience != audience: + return refuse("is for another authority") + case digest != hex.EncodeToString(want[:]): + return refuse("was made for another workload credential") + case key != hex.EncodeToString(public): + return refuse("names another key") + case err != nil || len(nonce) != 32 || hex.EncodeToString(nonce) != got.Nonce: + return refuse("carries no 32-byte nonce") + case got.Issued > now.Add(Skew).Unix(): + return refuse("is signed ahead of the authority's clock") + case got.Issued <= now.Add(-RegistrationWindow).Unix(): + return refuse("is stale: sign a fresh one for each registration") + } + return got, nil +} diff --git a/libraries/authority-client/go/stamp/registration_test.go b/libraries/authority-client/go/stamp/registration_test.go new file mode 100644 index 00000000..d50a496e --- /dev/null +++ b/libraries/authority-client/go/stamp/registration_test.go @@ -0,0 +1,75 @@ +package stamp + +import ( + "crypto/ed25519" + "strings" + "testing" + "time" +) + +// Review #28 F1: a registration proves possession of the admission key. The +// key itself signs the credential it is presented with, the key and the +// authority's audience, fresh; a proof for anything else is refused. +func TestARegistrationProofBindsCredentialKeyAudienceAndTime(t *testing.T) { + hop, err := NewSigner(strings.Repeat("02", 32)) + if err != nil { + t.Fatal(err) + } + other, err := NewSigner(strings.Repeat("03", 32)) + if err != nil { + t.Fatal(err) + } + _, public := hop.Admission() + _, otherPublic := other.Admission() + now := time.Unix(1700000000, 0) + const credential, audience = "hop-credential", "https://authority.test" + proof, err := hop.ProveRegistration(credential, audience, now) + if err != nil { + t.Fatal(err) + } + got, err := CheckRegistration(proof, public, credential, audience, now) + if err != nil || got.Issued != now.Unix() || len(got.Nonce) != 64 { + t.Fatalf("a fresh proof by the key was refused: %+v %v", got, err) + } + again, _ := hop.ProveRegistration(credential, audience, now) + if second, _ := CheckRegistration(again, public, credential, audience, now); second.Nonce == got.Nonce { + t.Fatal("two proofs share a nonce") + } + forged, _ := other.ProveRegistration(credential, audience, now) + payload, signature, _ := strings.Cut(proof, ".") + _, otherSignature, _ := strings.Cut(forged, ".") + for name, tc := range map[string]struct { + proof, credential, audience string + public ed25519.PublicKey + now time.Time + want string + }{ + "none": {"", credential, audience, public, now, "missing"}, + "another key's proof": {forged, credential, audience, public, now, "not signed by the key being registered"}, + "a key without its own": {proof, credential, audience, otherPublic, now, "not signed by the key being registered"}, + "spliced signature": {payload + "." + otherSignature, credential, audience, public, now, "not signed by the key being registered"}, + "padded signature": {payload + "." + signature + "=", credential, audience, public, now, "not canonical"}, + "another credential": {proof, "stolen-credential", audience, public, now, "another workload credential"}, + "another authority": {proof, credential, "https://elsewhere.test", public, now, "another authority"}, + "stale": {proof, credential, audience, public, now.Add(RegistrationWindow), "stale"}, + "ahead of the clock": {proof, credential, audience, public, now.Add(-Skew - time.Second), "ahead"}, + } { + if _, err := CheckRegistration(tc.proof, tc.public, tc.credential, tc.audience, tc.now); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: %v, want %q", name, err, tc.want) + } + } + if _, err := CheckRegistration(proof, public, credential, audience, now.Add(RegistrationWindow-time.Second)); err != nil { + t.Fatalf("a proof inside its window was refused: %v", err) + } + // A call admission is never a registration proof, though the same key signs both. + call, err := hop.Sign("token", "/pkg.Service/Read", "module", now, now.Add(time.Minute).Unix()) + if err != nil { + t.Fatal(err) + } + if _, err := CheckRegistration(call, public, credential, audience, now); err == nil { + t.Fatal("a call admission was accepted as a registration proof") + } + if _, err := hop.ProveRegistration("", audience, now); err == nil { + t.Fatal("a proof without a credential was signed") + } +} diff --git a/libraries/authority-client/library.codefly.yaml b/libraries/authority-client/library.codefly.yaml index 71aa1236..0576d35d 100644 --- a/libraries/authority-client/library.codefly.yaml +++ b/libraries/authority-client/library.codefly.yaml @@ -20,7 +20,7 @@ sources: module: authority service: local endpoint: grpc - contract-digest: sha256:1c51ec903d141b1df6c0eff0fcaa10aa48b6633e0753a13718bef83d086dc0cb + contract-digest: sha256:3f768e844a891e1554b597e586afb39720a2bd6cdb89d99da803e2d54db4204b services: - LocalService generated-by: 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 2441c455..113932dd 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\"\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') +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\"\x8f\x01\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\x12\x14\n\x05proof\x18\x03 \x01(\tR\x05proof\"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) @@ -65,28 +65,28 @@ _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 + _globals['_REGISTERADMISSIONKEYREQUEST']._serialized_start=2294 + _globals['_REGISTERADMISSIONKEYREQUEST']._serialized_end=2437 + _globals['_REGISTERADMISSIONKEYRESPONSE']._serialized_start=2439 + _globals['_REGISTERADMISSIONKEYRESPONSE']._serialized_end=2557 + _globals['_LISTRESOURCESREQUEST']._serialized_start=2560 + _globals['_LISTRESOURCESREQUEST']._serialized_end=2693 + _globals['_LISTRESOURCESRESPONSE']._serialized_start=2695 + _globals['_LISTRESOURCESRESPONSE']._serialized_end=2776 + _globals['_ATTACHRESOURCEREQUEST']._serialized_start=2779 + _globals['_ATTACHRESOURCEREQUEST']._serialized_end=2955 + _globals['_ATTACHRESOURCERESPONSE']._serialized_start=2957 + _globals['_ATTACHRESOURCERESPONSE']._serialized_end=3022 + _globals['_DETACHRESOURCEREQUEST']._serialized_start=3025 + _globals['_DETACHRESOURCEREQUEST']._serialized_end=3220 + _globals['_DETACHRESOURCERESPONSE']._serialized_start=3222 + _globals['_DETACHRESOURCERESPONSE']._serialized_end=3287 + _globals['_AUTHORIZETRANSFERREQUEST']._serialized_start=3290 + _globals['_AUTHORIZETRANSFERREQUEST']._serialized_end=3545 + _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_start=3547 + _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_end=3646 + _globals['_RESOURCEGRANT']._serialized_start=3649 + _globals['_RESOURCEGRANT']._serialized_end=3809 + _globals['_AUTHORITYSERVICE']._serialized_start=3812 + _globals['_AUTHORITYSERVICE']._serialized_end=4871 # @@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 1823c757..b1d8135b 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 @@ -127,9 +127,13 @@ def Keys(self, request, context): 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 + caller is the hop, proving it with its workload credential, in the + authority's own trust domain; any other workload is refused by name. The + hop also proves it holds the key: the request carries a fresh proof signed + by the admission key itself over the credential, the key and the + authority's audience, and a stale, replayed or unsigned registration is + refused, so a captured credential alone registers nothing. 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 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 c910b471..528fbdf0 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("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCKAAQoMS2V5c1Jlc3BvbnNlEg4KBmlzc3VlchgBIAEoCRIqCgRrZXlzGAIgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5EjQKDmFkbWlzc2lvbl9rZXlzGAMgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5IlMKCVB1YmxpY0tleRIOCgZrZXlfaWQYASABKAkSEQoJYWxnb3JpdGhtGAIgASgJEhIKCnB1YmxpY19rZXkYAyABKAwSDwoHcHVycG9zZRgEIAEoCSJnChtSZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEgoKcHVibGljX2tleRgCIAEoDCJiChxSZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEikKA2tleRgBIAEoCzIcLmhvc3QuYXV0aG9yaXR5LnYxLlB1YmxpY0tleRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMiXgoUTGlzdFJlc291cmNlc1JlcXVlc3QSDQoFdG9rZW4YASABKAkSEAoIYXVkaWVuY2UYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRIOCgZhY3Rpb24YBCABKAkiSQoVTGlzdFJlc291cmNlc1Jlc3BvbnNlEjAKBmdyYW50cxgBIAMoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLlJlc291cmNlR3JhbnQiiAEKFUF0dGFjaFJlc291cmNlUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJIjEKFkF0dGFjaFJlc291cmNlUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJIpIBChVEZXRhY2hSZXNvdXJjZVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWRGV0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiuwEKGEF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJEhUKDWZyb21fb3duZXJfaWQYBSABKAkSFwoPdG9fcHJpbmNpcGFsX2lkGAYgASgJIkgKGUF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USFAoMcHJpbmNpcGFsX2lkGAEgASgJEhUKDWF1dGhvcml6ZWRfYnkYAiABKAkidQoNUmVzb3VyY2VHcmFudBINCgNhbGwYASABKAhIABIMCgJpZBgCIAEoCUgAEhAKBnByZWZpeBgDIAEoCUgAEhEKB3BhdHRlcm4YBCABKAlIABIZChFyZXNvdXJjZV9vd25lcl9pZBgFIAEoCUIHCgVtYXRjaDLjCAoQQXV0aG9yaXR5U2VydmljZRJqCgVJc3N1ZRIfLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTppc3N1ZRJqCgVDaGVjaxIfLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTpjaGVjaxJHCgRLZXlzEh4uaG9zdC5hdXRob3JpdHkudjEuS2V5c1JlcXVlc3QaHy5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVzcG9uc2USdwoUUmVnaXN0ZXJBZG1pc3Npb25LZXkSLi5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QaLy5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEmIKDUxpc3RSZXNvdXJjZXMSJy5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVxdWVzdBooLmhvc3QuYXV0aG9yaXR5LnYxLkxpc3RSZXNvdXJjZXNSZXNwb25zZRJlCg5BdHRhY2hSZXNvdXJjZRIoLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVxdWVzdBopLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVzcG9uc2USZQoORGV0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlc3BvbnNlEm4KEUF1dGhvcml6ZVRyYW5zZmVyEisuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0GiwuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRJNCghEZWxlZ2F0ZRIiLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRlUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb24SawoQUmV2b2tlRGVsZWdhdGlvbhIqLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXF1ZXN0GisuaG9zdC5hdXRob3JpdHkudjEuUmV2b2tlRGVsZWdhdGlvblJlc3BvbnNlElcKDUdldERlbGVnYXRpb24SJy5ob3N0LmF1dGhvcml0eS52MS5HZXREZWxlZ2F0aW9uUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25CL1otZ2F0ZXdheS9wa2cvZ2VuL2hvc3QvYXV0aG9yaXR5L3YxO2F1dGhvcml0eXYxYgZwcm90bzM", [file_google_api_annotations, file_host_authority_v1_work_context]); + fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCKAAQoMS2V5c1Jlc3BvbnNlEg4KBmlzc3VlchgBIAEoCRIqCgRrZXlzGAIgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5EjQKDmFkbWlzc2lvbl9rZXlzGAMgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5IlMKCVB1YmxpY0tleRIOCgZrZXlfaWQYASABKAkSEQoJYWxnb3JpdGhtGAIgASgJEhIKCnB1YmxpY19rZXkYAyABKAwSDwoHcHVycG9zZRgEIAEoCSJ2ChtSZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEgoKcHVibGljX2tleRgCIAEoDBINCgVwcm9vZhgDIAEoCSJiChxSZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEikKA2tleRgBIAEoCzIcLmhvc3QuYXV0aG9yaXR5LnYxLlB1YmxpY0tleRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMiXgoUTGlzdFJlc291cmNlc1JlcXVlc3QSDQoFdG9rZW4YASABKAkSEAoIYXVkaWVuY2UYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRIOCgZhY3Rpb24YBCABKAkiSQoVTGlzdFJlc291cmNlc1Jlc3BvbnNlEjAKBmdyYW50cxgBIAMoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLlJlc291cmNlR3JhbnQiiAEKFUF0dGFjaFJlc291cmNlUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJIjEKFkF0dGFjaFJlc291cmNlUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJIpIBChVEZXRhY2hSZXNvdXJjZVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWRGV0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiuwEKGEF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJEhUKDWZyb21fb3duZXJfaWQYBSABKAkSFwoPdG9fcHJpbmNpcGFsX2lkGAYgASgJIkgKGUF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USFAoMcHJpbmNpcGFsX2lkGAEgASgJEhUKDWF1dGhvcml6ZWRfYnkYAiABKAkidQoNUmVzb3VyY2VHcmFudBINCgNhbGwYASABKAhIABIMCgJpZBgCIAEoCUgAEhAKBnByZWZpeBgDIAEoCUgAEhEKB3BhdHRlcm4YBCABKAlIABIZChFyZXNvdXJjZV9vd25lcl9pZBgFIAEoCUIHCgVtYXRjaDLjCAoQQXV0aG9yaXR5U2VydmljZRJqCgVJc3N1ZRIfLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTppc3N1ZRJqCgVDaGVjaxIfLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTpjaGVjaxJHCgRLZXlzEh4uaG9zdC5hdXRob3JpdHkudjEuS2V5c1JlcXVlc3QaHy5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVzcG9uc2USdwoUUmVnaXN0ZXJBZG1pc3Npb25LZXkSLi5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QaLy5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEmIKDUxpc3RSZXNvdXJjZXMSJy5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVxdWVzdBooLmhvc3QuYXV0aG9yaXR5LnYxLkxpc3RSZXNvdXJjZXNSZXNwb25zZRJlCg5BdHRhY2hSZXNvdXJjZRIoLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVxdWVzdBopLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVzcG9uc2USZQoORGV0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlc3BvbnNlEm4KEUF1dGhvcml6ZVRyYW5zZmVyEisuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0GiwuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRJNCghEZWxlZ2F0ZRIiLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRlUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb24SawoQUmV2b2tlRGVsZWdhdGlvbhIqLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXF1ZXN0GisuaG9zdC5hdXRob3JpdHkudjEuUmV2b2tlRGVsZWdhdGlvblJlc3BvbnNlElcKDUdldERlbGVnYXRpb24SJy5ob3N0LmF1dGhvcml0eS52MS5HZXREZWxlZ2F0aW9uUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25CL1otZ2F0ZXdheS9wa2cvZ2VuL2hvc3QvYXV0aG9yaXR5L3YxO2F1dGhvcml0eXYxYgZwcm90bzM", [file_google_api_annotations, file_host_authority_v1_work_context]); /** * IssueRequest names on whose behalf the capability is issued. @@ -626,6 +626,20 @@ export type RegisterAdmissionKeyRequest = Message<"host.authority.v1.RegisterAdm * @generated from field: bytes public_key = 2; */ publicKey: Uint8Array; + + /** + * Proof that the caller holds public_key's private half: a compact + * "." in unpadded base64url, the signature an Ed25519 + * signature by public_key over the payload, a JSON array of the domain + * "codefly-hop-registration-v1", the authority's audience, the hex SHA-256 + * of actor's credential, the hex public key, a fresh 32-byte hex nonce and + * the Unix second it was signed. The authority refuses a proof that does + * not verify, names another credential, key or audience, is older than 30 + * seconds or ahead of its clock, or repeats a nonce it has seen. + * + * @generated from field: string proof = 3; + */ + proof: string; }; /** @@ -1044,9 +1058,13 @@ export const AuthorityService: GenService<{ /** * 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 + * caller is the hop, proving it with its workload credential, in the + * authority's own trust domain; any other workload is refused by name. The + * hop also proves it holds the key: the request carries a fresh proof signed + * by the admission key itself over the credential, the key and the + * authority's audience, and a stale, replayed or unsigned registration is + * refused, so a captured credential alone registers nothing. 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 diff --git a/modules/authority/module.codefly.yaml b/modules/authority/module.codefly.yaml index ff165f99..e10d7510 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.3 + - codefly.dev/authority@0.3.4 - codefly.dev/composition@0.2.0 - codefly.dev/installation@0.2.0 - codefly.dev/session@0.2.0 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 d580a1dd..eaae857a 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 @@ -1130,7 +1130,16 @@ type RegisterAdmissionKeyRequest struct { // 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"` + PublicKey []byte `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + // Proof that the caller holds public_key's private half: a compact + // "." in unpadded base64url, the signature an Ed25519 + // signature by public_key over the payload, a JSON array of the domain + // "codefly-hop-registration-v1", the authority's audience, the hex SHA-256 + // of actor's credential, the hex public key, a fresh 32-byte hex nonce and + // the Unix second it was signed. The authority refuses a proof that does + // not verify, names another credential, key or audience, is older than 30 + // seconds or ahead of its clock, or repeats a nonce it has seen. + Proof string `protobuf:"bytes,3,opt,name=proof,proto3" json:"proof,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1179,6 +1188,13 @@ func (x *RegisterAdmissionKeyRequest) GetPublicKey() []byte { return nil } +func (x *RegisterAdmissionKeyRequest) GetProof() string { + if x != nil { + return x.Proof + } + return "" +} + // RegisterAdmissionKeyResponse is the key as Keys now publishes it. type RegisterAdmissionKeyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1948,11 +1964,12 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n" + "\n" + "public_key\x18\x03 \x01(\fR\tpublicKey\x12\x18\n" + - "\apurpose\x18\x04 \x01(\tR\apurpose\"y\n" + + "\apurpose\x18\x04 \x01(\tR\apurpose\"\x8f\x01\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" + + "public_key\x18\x02 \x01(\fR\tpublicKey\x12\x14\n" + + "\x05proof\x18\x03 \x01(\tR\x05proof\"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" + 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 02afed32..ba36f128 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 @@ -60,9 +60,13 @@ type AuthorityServiceClient interface { 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 + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. The + // hop also proves it holds the key: the request carries a fresh proof signed + // by the admission key itself over the credential, the key and the + // authority's audience, and a stale, replayed or unsigned registration is + // refused, so a captured credential alone registers nothing. 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 @@ -274,9 +278,13 @@ type AuthorityServiceServer interface { 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 + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. The + // hop also proves it holds the key: the request carries a fresh proof signed + // by the admission key itself over the credential, the key and the + // authority's audience, and a stale, replayed or unsigned registration is + // refused, so a captured credential alone registers nothing. 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 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 ebd0b27e..96eb73af 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 @@ -88,9 +88,13 @@ type AuthorityServiceClient interface { 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 + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. The + // hop also proves it holds the key: the request carries a fresh proof signed + // by the admission key itself over the credential, the key and the + // authority's audience, and a stale, replayed or unsigned registration is + // refused, so a captured credential alone registers nothing. 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 @@ -330,9 +334,13 @@ type AuthorityServiceHandler interface { 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 + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. The + // hop also proves it holds the key: the request carries a fresh proof signed + // by the admission key itself over the credential, the key and the + // authority's audience, and a stale, replayed or unsigned registration is + // refused, so a captured credential alone registers nothing. 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 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 104828ea..c1907b03 100644 --- a/modules/authority/services/local/proto/host/authority/v1/authority.proto +++ b/modules/authority/services/local/proto/host/authority/v1/authority.proto @@ -32,9 +32,13 @@ service AuthorityService { 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 + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. The + // hop also proves it holds the key: the request carries a fresh proof signed + // by the admission key itself over the credential, the key and the + // authority's audience, and a stale, replayed or unsigned registration is + // refused, so a captured credential alone registers nothing. 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 @@ -306,6 +310,15 @@ message RegisterAdmissionKeyRequest { WorkloadCredential actor = 1; // The raw Ed25519 public key the hop signs admissions with: 32 bytes. bytes public_key = 2; + // Proof that the caller holds public_key's private half: a compact + // "." in unpadded base64url, the signature an Ed25519 + // signature by public_key over the payload, a JSON array of the domain + // "codefly-hop-registration-v1", the authority's audience, the hex SHA-256 + // of actor's credential, the hex public key, a fresh 32-byte hex nonce and + // the Unix second it was signed. The authority refuses a proof that does + // not verify, names another credential, key or audience, is older than 30 + // seconds or ahead of its clock, or repeats a nonce it has seen. + string proof = 3; } // RegisterAdmissionKeyResponse is the key as Keys now publishes it. diff --git a/tools/noproxygate/generated.json b/tools/noproxygate/generated.json index 485efeb0..97be4f0a 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": "c1624bcd92bee470ee22ad4a868f4fc2ba53fc5d5250fec4b7c3f9c0d076da1e", - "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "4e0519a8f4d9d337b67f71cece2ab68978ca3ba63a1b840cb7d13dcd3aceb080", + "libraries/authority-client/go/gen/host/authority/v1/authority.pb.go": "d3a1f7c471d19c69260ab1436e9b464c872907c40e4ddd27cf50c1993071cf37", + "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "43fe2868d420ce5f96c6c7986d142515a720ffd50793c1a43fe7cadc223b835e", "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": "cb634089e403795182aec0c193a8f16decf894cf98cab020712b86f8a3fb1696", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go": "81ab23cd032fe5a43e25e33dd9991bbb4d5e89a474936525e75029929eb8e3ea", "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": "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/authority_grpc.pb.go": "43fe2868d420ce5f96c6c7986d142515a720ffd50793c1a43fe7cadc223b835e", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go": "7136aac3d033ad0359a04b90413d86d12bb1f0e9d889616b45241e538d9f10e4", "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", From 9442eb5c6d2125e9c791899506ff63b996ccc048 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:59:48 -0400 Subject: [PATCH 2/5] fix: hop registration proves the key; REST callers keep {"error"}; setup page hardened Follow-ups from the reviews of codefly-dev/authority#28 (round e) and #30. #28 F1 (proof of possession): hop.Registration signs each registration with the admission key itself (stamp.ProveRegistration) over the audience its credential names, the credential's digest and the key, fresh; the local authority verifies it (stamp.CheckRegistration), refuses a missing, mismatched, stale or replayed proof, and remembers each nonce while the proof could pass as fresh (bounded). A captured hop credential alone now registers nothing. Pins hop/go and the local service to dc49cde. #28 F2: the authority also requires the hop's trust domain to be the host's: the one trust domain the run's anchor declares when it starts. #28 F4: the hop names the published key by its id in a line the run's log redaction leaves alone ("the authority ... key admission=" was masked as "admission=****"), and a refusal by its reason; never the seed. #30 F1: hop and edge answer in Connect's words only a caller that says it speaks Connect (WithRequireConnectProtocolHeader) or gRPC; REST and form callers keep {"error": reason} and the status mapping they always had. #30 F2: sign-in and setup send X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none'. #30 F3: /auth/setup is mounted only where the host serves OfferService (asked once without a credential; Unimplemented is 404, unreachable 503). #30 F4: a test that another organization's administrator is refused ListOffers and ReviewOffer. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++ libraries/hop/README.md | 7 +- libraries/hop/go/go.mod | 2 +- libraries/hop/go/go.sum | 4 +- libraries/hop/go/register.go | 50 ++++++- libraries/hop/go/register_test.go | 65 ++++++++- modules/authority/services/edge/README.md | 15 +- modules/authority/services/edge/code/edge.go | 23 ++- modules/authority/services/edge/code/setup.go | 37 +++++ .../services/edge/code/setup_test.go | 99 +++++++++++++ .../authority/services/edge/code/signin.go | 1 + .../services/hop/code/pkg/infra/proxy/http.go | 12 +- .../hop/code/pkg/infra/proxy/http_test.go | 51 +++++++ modules/authority/services/hop/code/work.go | 27 ++-- .../authority/services/hop/code/work_test.go | 57 ++++++++ modules/authority/services/local/code/go.mod | 2 +- modules/authority/services/local/code/go.sum | 4 +- .../code/pkg/business/issuer/admission.go | 45 +++++- .../pkg/business/issuer/admission_test.go | 133 +++++++++++++++--- .../code/pkg/business/issuer/export_test.go | 4 + .../local/code/pkg/business/issuer/issuer.go | 8 +- .../local/code/pkg/business/state/state.go | 31 +++- .../pkg/business/state/trust_domain_test.go | 50 +++++++ .../local/code/plugins/authority/plugin.go | 5 +- .../code/plugins/installation/offers_test.go | 23 +++ .../registry_test/hop_admission_test.go | 33 ++++- 26 files changed, 733 insertions(+), 66 deletions(-) create mode 100644 modules/authority/services/local/code/pkg/business/state/trust_domain_test.go diff --git a/README.md b/README.md index 6fec267b..dd4115d7 100644 --- a/README.md +++ b/README.md @@ -1044,6 +1044,17 @@ 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. +`codefly.dev/authority@0.3.4` makes the registration prove possession of the +key: `RegisterAdmissionKeyRequest.proof` is an Ed25519 signature by the +admission key itself over the authority's audience, the SHA-256 of the +presented credential, the key and a fresh nonce and second +(`stamp.ProveRegistration`, `stamp.CheckRegistration`). The authority refuses a +missing, mismatched, stale (over 30 seconds) or replayed proof, and a hop +attested in any trust domain but its own (the one the run's anchor declares its +workloads under), so a captured hop credential alone registers nothing. The +field is additive on the wire; an authority at 0.3.4 refuses a 0.3.3 hop's +unproved registration, so the hop and the authority move together. + 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/libraries/hop/README.md b/libraries/hop/README.md index fe65119f..f5fb4f1a 100644 --- a/libraries/hop/README.md +++ b/libraries/hop/README.md @@ -203,7 +203,12 @@ 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 +credential, and the authority refuses any other workload by name. Since +`codefly.dev/authority@0.3.4` each registration also carries a fresh proof +signed by the admission key over that credential, the key and the audience the +credential names (`stamp.ProveRegistration`), so a captured credential alone +registers nothing; the authority refuses a stale or replayed proof, and a hop +outside its own trust domain. The hop logs the published key by its id. 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 diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index 0d9e4ca1..f15168b5 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -3,7 +3,7 @@ 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-20261026184108-e1966db4fe66 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026204838-dc49cde52d89 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 diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index 9a44f054..278b7882 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-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/authority/libraries/authority-client/go v0.0.0-20261026204838-dc49cde52d89 h1:jLNpRN8n+Mo3CKjeQnXzGIVRrqZ7USZXjgjFq+svPdE= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026204838-dc49cde52d89/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= diff --git a/libraries/hop/go/register.go b/libraries/hop/go/register.go index cf805278..4ca9482a 100644 --- a/libraries/hop/go/register.go +++ b/libraries/hop/go/register.go @@ -2,6 +2,10 @@ package hop import ( "context" + "encoding/base64" + "encoding/json" + "errors" + "strings" "sync" "time" @@ -21,7 +25,10 @@ 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 +// the public half with its workload credential, never its seed, and a fresh +// proof signed by the admission key over that credential, the key and the +// authority's audience (stamp.ProveRegistration), so the credential alone +// registers nothing. 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 { @@ -42,9 +49,17 @@ func (r Registration) Register(ctx context.Context) (*authorityv1.PublicKey, tim if err != nil { return nil, time.Time{}, err } + audience, err := CredentialAudience(credential) + if err != nil { + return nil, time.Time{}, err + } + proof, err := r.Signer.ProveRegistration(credential, audience, time.Now()) + 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, + Actor: &authorityv1.WorkloadCredential{Credential: credential}, PublicKey: public, Proof: proof, }) if err != nil { return nil, time.Time{}, err @@ -52,6 +67,37 @@ func (r Registration) Register(ctx context.Context) (*authorityv1.PublicKey, tim return out.GetKey(), time.Unix(out.GetExpiresAtUnix(), 0), nil } +// CredentialAudience is the audience the launcher issued this workload's +// credential for: the authority the credential is presented to, which a +// registration proof names. It reads the hop's own credential and verifies +// nothing; the authority attests the credential and refuses a proof naming +// any audience but its own. +func CredentialAudience(credential string) (string, error) { + parts := strings.Split(credential, ".") + if len(parts) != 3 { + return "", errors.New("hop: this workload's credential is not a compact JWS, so it names no audience to prove a registration to") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", errors.New("hop: this workload's credential has an unreadable payload") + } + var claims struct { + Audience json.RawMessage `json:"aud"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "", errors.New("hop: this workload's credential has unreadable claims") + } + var one string + var many []string + switch { + case json.Unmarshal(claims.Audience, &one) == nil && one != "": + return one, nil + case json.Unmarshal(claims.Audience, &many) == nil && len(many) == 1 && many[0] != "": + return many[0], nil + } + return "", errors.New("hop: this workload's credential names no single audience to prove a registration to") +} + // 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 diff --git a/libraries/hop/go/register_test.go b/libraries/hop/go/register_test.go index 1dfd824e..287d21cf 100644 --- a/libraries/hop/go/register_test.go +++ b/libraries/hop/go/register_test.go @@ -2,7 +2,10 @@ package hop import ( "context" + "crypto/ed25519" + "encoding/base64" "errors" + "strings" "sync" "testing" "time" @@ -13,13 +16,24 @@ import ( ) // registrar is an authority answering RegisterAdmissionKey with a lease, or -// refusing while refuse is set; it records what it was presented. +// refusing while refuse is set; it records what it was presented, and checks +// each registration's proof as the authority at testAudience does. type registrar struct { mu sync.Mutex lease time.Duration refuse error credentials []string keys [][]byte + nonces []string +} + +const testAudience = "https://authority.hop.test" + +// testCredential is a launcher-shaped credential for audience; only its +// claims are read here, the authority attests it. +func testCredential(audience, id string) string { + encode := func(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) } + return encode(`{"alg":"EdDSA"}`) + "." + encode(`{"aud":["`+audience+`"],"jti":"`+id+`"}`) + "." + encode("signature") } func (r *registrar) RegisterAdmissionKey(_ context.Context, in *authorityv1.RegisterAdmissionKeyRequest, _ ...grpc.CallOption) (*authorityv1.RegisterAdmissionKeyResponse, error) { @@ -30,6 +44,11 @@ func (r *registrar) RegisterAdmissionKey(_ context.Context, in *authorityv1.Regi if r.refuse != nil { return nil, r.refuse } + proved, err := stamp.CheckRegistration(in.GetProof(), ed25519.PublicKey(in.GetPublicKey()), in.GetActor().GetCredential(), testAudience, time.Now()) + if err != nil { + return nil, err + } + r.nonces = append(r.nonces, proved.Nonce) return &authorityv1.RegisterAdmissionKeyResponse{ Key: &authorityv1.PublicKey{KeyId: stamp.KeyID(in.GetPublicKey()), PublicKey: in.GetPublicKey(), Purpose: stamp.Purpose}, ExpiresAtUnix: time.Now().Add(r.lease).Unix(), @@ -52,7 +71,10 @@ func TestTheHopKeepsItsPublicKeyRegisteredUnderItsCredential(t *testing.T) { 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 }, + Credential: func() (string, error) { + rotation++ + return testCredential(testAudience, "credential-"+string(rune('0'+rotation))), nil + }, Report: func(key *authorityv1.PublicKey, _ time.Time, err error) { mu.Lock() defer mu.Unlock() @@ -86,6 +108,11 @@ func TestTheHopKeepsItsPublicKeyRegisteredUnderItsCredential(t *testing.T) { if authority.credentials[0] == authority.credentials[1] { t.Fatal("the credential was not read fresh for the renewal") } + // Review #28 F1: each registration proves possession of the key, over + // that registration's credential, with a nonce of its own. + if len(authority.nonces) != len(authority.keys) || authority.nonces[0] == authority.nonces[1] { + t.Fatalf("registrations without a fresh proof each: %v", authority.nonces) + } mu.Lock() defer mu.Unlock() if len(reports) != 1 { @@ -100,7 +127,7 @@ func TestARefusedRegistrationIsReportedAndRetried(t *testing.T) { 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 }, + Credential: func() (string, error) { return testCredential(testAudience, "credential"), nil }, Report: func(_ *authorityv1.PublicKey, _ time.Time, err error) { got = err }}.Keep(context.Background()) stop() if !errors.Is(got, refused) { @@ -113,3 +140,35 @@ func TestARefusedRegistrationIsReportedAndRetried(t *testing.T) { t.Fatalf("a hop with no credential registered: %v (%d calls)", err, none.calls()) } } + +// The proof names the audience the credential was issued for, so it is good +// at that authority alone; a credential naming none presents nothing. +func TestARegistrationProofNamesTheCredentialsAudience(t *testing.T) { + signer := testSigner("the-hop") + _, public := signer.Admission() + var seen *authorityv1.RegisterAdmissionKeyRequest + authority := registrarFunc(func(in *authorityv1.RegisterAdmissionKeyRequest) { seen = in }) + credential := testCredential("https://elsewhere.test", "c1") + if _, _, err := (Registration{Authority: authority, Signer: signer, Credential: func() (string, error) { return credential, nil }}).Register(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := stamp.CheckRegistration(seen.GetProof(), public, credential, "https://elsewhere.test", time.Now()); err != nil { + t.Fatalf("the proof is not for the credential's audience: %v", err) + } + if _, err := stamp.CheckRegistration(seen.GetProof(), public, credential, testAudience, time.Now()); err == nil || !strings.Contains(err.Error(), "another authority") { + t.Fatalf("a proof for one authority was good at another: %v", err) + } + seen = nil + for _, bad := range []string{"opaque-bearer", testCredential("", "c2")} { + if _, _, err := (Registration{Authority: authority, Signer: signer, Credential: func() (string, error) { return bad, nil }}).Register(context.Background()); err == nil || seen != nil { + t.Fatalf("%q registered with no audience to prove to: %v", bad, err) + } + } +} + +type registrarFunc func(*authorityv1.RegisterAdmissionKeyRequest) + +func (f registrarFunc) RegisterAdmissionKey(_ context.Context, in *authorityv1.RegisterAdmissionKeyRequest, _ ...grpc.CallOption) (*authorityv1.RegisterAdmissionKeyResponse, error) { + f(in) + return &authorityv1.RegisterAdmissionKeyResponse{Key: &authorityv1.PublicKey{KeyId: stamp.KeyID(in.GetPublicKey())}, ExpiresAtUnix: time.Now().Add(time.Minute).Unix()}, nil +} diff --git a/modules/authority/services/edge/README.md b/modules/authority/services/edge/README.md index 5507e49f..0f1a5140 100644 --- a/modules/authority/services/edge/README.md +++ b/modules/authority/services/edge/README.md @@ -63,8 +63,15 @@ client; the configuration supplies neither credentials nor permissions. ## The setup page -`/auth/setup` installs the run's solutions and grants their roles. It needs a -session, and without one it redirects to sign-in. The page is the same embedded +`/auth/setup` installs the run's solutions and grants their roles. It is +mounted only where the host serves `OfferService`, which the local authority +does and a hosted authority does not: the edge asks the host once, without a +credential, and a host answering `Unimplemented` gets a 404 for the path (no +install, grant or revoke is ever sent to it); an unreachable host is a 503 and +is asked again. It needs a session, and without one it redirects to sign-in. +Neither it nor the sign-in page may be framed (`X-Frame-Options: DENY`, +`Content-Security-Policy: frame-ancestors 'none'`), so another page, a +same-site one on another local port included, cannot overlay a click on it. The page is the same embedded build as sign-in (`web/src/setup.tsx`). The edge writes into its data island what the authority answers to the person's own session: @@ -85,4 +92,6 @@ is shown with the authority's reason. An install the authority did not confirm is shown again with its exact request, so the retry is the same operation. Calls the edge routes to a module are refused in Connect's words when the caller -speaks Connect, so a generated client keeps the reason (the hop does the same). +says it speaks Connect (a `Connect-Protocol-Version` header, or `?connect=v1` on a +GET) or gRPC, so a generated client keeps the reason (the hop does the same). +Every other caller, a REST or form caller included, keeps `{"error": reason}`. diff --git a/modules/authority/services/edge/code/edge.go b/modules/authority/services/edge/code/edge.go index fc7322ca..13dbadaa 100644 --- a/modules/authority/services/edge/code/edge.go +++ b/modules/authority/services/edge/code/edge.go @@ -85,6 +85,9 @@ type Edge struct { mu sync.Mutex held map[[32]byte]held cacheGeneration uint64 + // setupKnown and setupServed are the host's answer to whether it serves + // OfferService, once it has given a definite one (servesSetup). + setupKnown, setupServed bool } type held struct { @@ -114,10 +117,16 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { e.signOut(w, r) return case "/auth/setup": - if e.Offers != nil && e.Installer != nil { + served, err := e.servesSetup(r.Context()) + switch { + case err != nil: + refuse(w, http.StatusServiceUnavailable, "the host did not say whether it serves the setup page: "+status.Convert(err).Message()) + case !served: + refuse(w, http.StatusNotFound, "this host serves no setup page: only the local authority serves OfferService") + default: e.setup(w, r) - return } + return } if strings.HasPrefix(r.URL.Path, "/auth/assets/") { e.signInAsset(w, r) @@ -453,15 +462,21 @@ func proxy(target *url.URL, token string, w http.ResponseWriter, r *http.Request }).ServeHTTP(w, r) } +// connectRefusal answers in Connect's (or gRPC's) words only a caller that +// says it speaks the protocol: a Connect-Protocol-Version header, or +// ?connect=v1 on a GET, or a gRPC content type. Every other caller, a REST or +// form caller included, keeps {"error": reason}. +var connectRefusal = connect.NewErrorWriter(connect.WithRequireConnectProtocolHeader()) + // refuseCall refuses a routed call; a Connect caller is answered in Connect's // words, so its client keeps the authority's reason. func refuseCall(w http.ResponseWriter, r *http.Request, code int, reason string) { - if writer := connect.NewErrorWriter(); writer.IsSupported(r) { + if connectRefusal.IsSupported(r) { c := connect.CodePermissionDenied if code == http.StatusUnauthorized { c = connect.CodeUnauthenticated } - _ = writer.Write(w, r, connect.NewError(c, errors.New(reason))) + _ = connectRefusal.Write(w, r, connect.NewError(c, errors.New(reason))) return } refuse(w, code, reason) diff --git a/modules/authority/services/edge/code/setup.go b/modules/authority/services/edge/code/setup.go index 724668bf..8c4f5760 100644 --- a/modules/authority/services/edge/code/setup.go +++ b/modules/authority/services/edge/code/setup.go @@ -75,7 +75,44 @@ var setupNotices = map[string]string{ "revoked": "Role revoked. What it gave is refused from the next call.", } +// servesSetup is whether the host serves OfferService, the setup page's read: +// the local authority does, a hosted authority does not, and the page (its +// installs and grants included) is mounted only where it does. The host is +// asked once, with no credential: Unimplemented is a definite no; any other +// answer, a refusal of the unauthenticated read included, is a yes. An +// unreachable host is no answer, and it is asked again. +func (e *Edge) servesSetup(ctx context.Context) (bool, error) { + if e.Offers == nil || e.Installer == nil { + return false, nil + } + e.mu.Lock() + known, served := e.setupKnown, e.setupServed + e.mu.Unlock() + if known { + return served, nil + } + _, err := e.Offers.ListOffers(ctx, &installationv1.ListOffersRequest{}) + switch status.Code(err) { + case codes.Unavailable, codes.DeadlineExceeded, codes.Canceled: + return false, err + } + served = status.Code(err) != codes.Unimplemented + e.mu.Lock() + e.setupKnown, e.setupServed = true, served + e.mu.Unlock() + return served, nil +} + +// forbidFraming keeps a page out of every frame: a page that changes +// authority on a click (sign-in, setup) must not be overlaid by another +// page, a same-site one on another local port included. +func forbidFraming(w http.ResponseWriter) { + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'") +} + func (e *Edge) setup(w http.ResponseWriter, r *http.Request) { + forbidFraming(w) if r.Method != http.MethodGet && r.Method != http.MethodPost { refuse(w, http.StatusMethodNotAllowed, "the setup page is read with GET and changed with POST") return diff --git a/modules/authority/services/edge/code/setup_test.go b/modules/authority/services/edge/code/setup_test.go index c78bb5dd..377fc7af 100644 --- a/modules/authority/services/edge/code/setup_test.go +++ b/modules/authority/services/edge/code/setup_test.go @@ -24,6 +24,10 @@ type setupHost struct { calls []any bearers []string refuse error + // offers, when set, is the host's answer to whether it serves + // OfferService: Unimplemented for a host that does not. + offers error + probes int } func (h *setupHost) record(ctx context.Context, call any) error { @@ -34,6 +38,19 @@ func (h *setupHost) record(ctx context.Context, call any) error { } func (h *setupHost) ListOffers(ctx context.Context, in *installationv1.ListOffersRequest, _ ...grpc.CallOption) (*installationv1.ListOffersResponse, error) { + h.mu.Lock() + offers := h.offers + if bearerIn(ctx) == "" { + h.probes++ + } + h.mu.Unlock() + if offers != nil { + return nil, offers + } + // The local authority refuses an unauthenticated read by name. + if bearerIn(ctx) == "" { + return nil, status.Error(codes.Unauthenticated, "no session") + } if h.refuse != nil { return nil, h.refuse } @@ -162,3 +179,85 @@ func TestAModuleRefusalKeepsItsReasonForAConnectCaller(t *testing.T) { t.Fatalf("answered %d: %s", res.StatusCode, body) } } + +// Review #30 F3: the setup page, its installs and grants included, is mounted +// only on a host that serves OfferService (the local authority). Another host +// answers Unimplemented, and the edge serves no setup page at all there, so no +// cookie-driven install is ever sent to it. An unreachable host is asked again. +func TestTheSetupPageIsMountedOnlyWhereTheHostServesOffers(t *testing.T) { + f, host := newSetup(t) + cookie := f.signIn(t) + host.offers = status.Error(codes.Unavailable, "connection refused") + if res, body := f.do(t, http.MethodGet, "/auth/setup", http.Header{"Cookie": {cookie.String()}}); res.StatusCode != http.StatusServiceUnavailable || !strings.Contains(body, "connection refused") { + t.Fatalf("an unreachable host answered %d: %s", res.StatusCode, body) + } + host.offers = status.Error(codes.Unimplemented, "unknown service host.installation.v1.OfferService") + install := url.Values{"action": {"install"}, "target": {"proposals"}, "role": {"viewer"}, "consent": {"on"}, "operationId": {"5d3c0b0e-8f5e-4c3f-9d55-6c1f1a0b7e21"}} + if res, body := f.post(t, cookie, f.edge.URL, install); res.StatusCode != http.StatusNotFound || !strings.Contains(body, "serves no setup page") { + t.Fatalf("a host without OfferService was sent a setup change: %d %s", res.StatusCode, body) + } + if res, _ := f.do(t, http.MethodGet, "/auth/setup", http.Header{"Cookie": {cookie.String()}}); res.StatusCode != http.StatusNotFound { + t.Fatalf("a host without OfferService served the setup page: %d", res.StatusCode) + } + if len(host.calls) != 0 || host.probes != 2 { + t.Fatalf("calls %v reached the host; it was asked %d times (once unreachable, once answered)", host.calls, host.probes) + } + // A definite answer is kept: the host is not asked on every request. + host.offers = nil + if res, _ := f.do(t, http.MethodGet, "/auth/setup", http.Header{"Cookie": {cookie.String()}}); res.StatusCode != http.StatusNotFound || host.probes != 2 { + t.Fatalf("the answer was not kept: %d after %d probes", res.StatusCode, host.probes) + } + + // With no setup clients at all, the path is not the frontend's either. + plain := newFixture(t) + if res, _ := plain.do(t, http.MethodGet, "/auth/setup", nil); res.StatusCode != http.StatusNotFound { + t.Fatalf("an edge with no setup clients answered %d", res.StatusCode) + } + if _, path := plain.frontend.last(); path != "" { + t.Fatalf("/auth/setup reached the frontend at %s", path) + } +} + +// Review #30 F2: the pages that change authority on a click are never framed. +func TestTheSignInAndSetupPagesRefuseToBeFramed(t *testing.T) { + f, _ := newSetup(t) + cookie := f.signIn(t) + for _, page := range []struct { + path string + header http.Header + }{{"/auth/sign-in", nil}, {"/auth/setup", http.Header{"Cookie": {cookie.String()}}}, {"/auth/setup?target=proposals", http.Header{"Cookie": {cookie.String()}}}} { + res, _ := f.do(t, http.MethodGet, page.path, page.header) + if res.StatusCode != http.StatusOK || res.Header.Get("X-Frame-Options") != "DENY" || res.Header.Get("Content-Security-Policy") != "frame-ancestors 'none'" { + t.Errorf("%s answered %d with X-Frame-Options %q and CSP %q", page.path, res.StatusCode, res.Header.Get("X-Frame-Options"), res.Header.Get("Content-Security-Policy")) + } + } + res, _ := f.post(t, cookie, f.edge.URL, url.Values{"action": {"install"}, "target": {"proposals"}}) + if res.Header.Get("X-Frame-Options") != "DENY" { + t.Errorf("the setup page re-rendered after a refused change can be framed") + } +} + +// Review #30 F1: only a caller that says it speaks Connect is refused in +// Connect's words; a REST or form caller keeps {"error": reason}. +func TestAModuleRefusalKeepsTheRESTBodyForARESTCaller(t *testing.T) { + f := newFixture(t) + for _, tc := range []struct{ name, path, contentType string }{ + {"JSON POST", "/v1/records/items", "application/json"}, + {"form POST", "/v1/records/items", "application/x-www-form-urlencoded"}, + {"no content type", "/v1/records/items", ""}, + {"JSON POST to a Connect path", "/records.v1.RecordsService/List", "application/json"}, + } { + header := http.Header{} + if tc.contentType != "" { + header.Set("Content-Type", tc.contentType) + } + res, body := f.do(t, http.MethodPost, tc.path, header) + if res.StatusCode != http.StatusUnauthorized || body != "{\"error\":\"no session: sign in at /auth/sign-in\"}\n" { + t.Errorf("%s: answered %d %s", tc.name, res.StatusCode, body) + } + } + res, body := f.do(t, http.MethodGet, "/v1/records/items", nil) + if res.StatusCode != http.StatusUnauthorized || !strings.Contains(body, `"error":`) { + t.Errorf("GET answered %d %s", res.StatusCode, body) + } +} diff --git a/modules/authority/services/edge/code/signin.go b/modules/authority/services/edge/code/signin.go index 38a073f4..61fe54cc 100644 --- a/modules/authority/services/edge/code/signin.go +++ b/modules/authority/services/edge/code/signin.go @@ -55,6 +55,7 @@ type signInData struct { } func (e *Edge) signInPage(w http.ResponseWriter, r *http.Request) { + forbidFraming(w) people, err := e.Sessions.ListFixturePeople(r.Context(), &sessionv1.ListFixturePeopleRequest{}) if err != nil { refuse(w, http.StatusBadGateway, "the authority did not list who signs in: "+status.Convert(err).Message()) 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 9b8a973e..67cc01ba 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/http.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/http.go @@ -100,13 +100,19 @@ func (h *HTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) { }).ServeHTTP(w, r) } +// connectRefusal answers in Connect's (or gRPC's) words only a caller that +// says it speaks the protocol: a Connect-Protocol-Version header, or +// ?connect=v1 on a GET, or a gRPC content type. Without the requirement every +// GET and every POST of an application/* body (a REST or form caller) would +// be taken for Connect, and a reader of {"error"} would lose the reason. +var connectRefusal = connect.NewErrorWriter(connect.WithRequireConnectProtocolHeader()) + // refuse answers a refusal in HTTP's words, its reason the decision's own; // a Connect caller gets Connect's error, so its client keeps the reason. func refuse(w http.ResponseWriter, r *http.Request, err error) { s := status.Convert(err) - writer := connect.NewErrorWriter() - if writer.IsSupported(r) { - _ = writer.Write(w, r, connect.NewError(connect.Code(s.Code()), errors.New(s.Message()))) + if connectRefusal.IsSupported(r) { + _ = connectRefusal.Write(w, r, connect.NewError(connect.Code(s.Code()), errors.New(s.Message()))) return } code := http.StatusInternalServerError 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 523e79eb..decb801f 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 @@ -167,3 +167,54 @@ func TestAConnectRefusalKeepsTheAuthoritysReason(t *testing.T) { t.Fatalf("answered %d %s", rec.Code, rec.Body.String()) } } + +// Review #30 F1: only a caller that says it speaks Connect (or gRPC) is +// refused in Connect's words. A REST or form caller, whose body is JSON or a +// form and who sends no Connect-Protocol-Version, keeps {"error": reason} and +// the HTTP status the hop always answered it with. +func TestARESTCallerKeepsItsErrorBodyAndStatus(t *testing.T) { + for code, want := range map[codes.Code]int{ + codes.Unauthenticated: http.StatusUnauthorized, + codes.PermissionDenied: http.StatusForbidden, + codes.InvalidArgument: http.StatusBadRequest, + codes.NotFound: http.StatusNotFound, + codes.Unavailable: http.StatusServiceUnavailable, + codes.DeadlineExceeded: http.StatusServiceUnavailable, + codes.FailedPrecondition: http.StatusInternalServerError, + } { + h := &HTTP{Signer: hoptest.Signer("rest"), Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { + return decide.Decision{}, status.Error(code, "the authority said why") + }} + for name, req := range map[string]*http.Request{ + "GET": httptest.NewRequest(http.MethodGet, "/v1/model/profiles", nil), + "JSON POST": withType(httptest.NewRequest(http.MethodPost, "/v1/model/invoke", strings.NewReader("{}")), "application/json"), + "form POST": withType(httptest.NewRequest(http.MethodPost, "/v1/model/invoke", strings.NewReader("a=b")), "application/x-www-form-urlencoded"), + } { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != want || rec.Body.String() != "{\"error\":\"the authority said why\"}\n" { + t.Errorf("%v, %s: answered %d %s, want %d {\"error\"}", code, name, rec.Code, rec.Body.String(), want) + } + } + } + // A Connect GET says so with ?connect=v1, and a gRPC caller by its content + // type: both are still answered in their protocol's words. + h := &HTTP{Signer: hoptest.Signer("rest"), Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { + return decide.Decision{}, status.Error(codes.PermissionDenied, "the role lacks robin.tools:read") + }} + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robin.chat.v1.ChatService/GetRunnableTools?connect=v1&encoding=json&message=%7B%7D", nil)) + if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), `"code":"permission_denied"`) { + t.Fatalf("a Connect GET answered %d %s", rec.Code, rec.Body.String()) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, withType(httptest.NewRequest(http.MethodPost, "/robin.chat.v1.ChatService/GetRunnableTools", strings.NewReader("")), "application/grpc")) + if got := rec.Result().Header.Get("Grpc-Status") + rec.Result().Trailer.Get("Grpc-Status"); got != "7" { + t.Fatalf("a gRPC caller answered %d grpc-status %q", rec.Code, got) + } +} + +func withType(r *http.Request, contentType string) *http.Request { + r.Header.Set("Content-Type", contentType) + return r +} diff --git a/modules/authority/services/hop/code/work.go b/modules/authority/services/hop/code/work.go index 40fed309..99a3252c 100644 --- a/modules/authority/services/hop/code/work.go +++ b/modules/authority/services/hop/code/work.go @@ -129,14 +129,9 @@ func standInFront(ctx context.Context, config *adapters.Configuration) (Clean, e } 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)) + // it, proving itself with its workload credential and the key's own + // proof: the seed stays here. + unregister := hop.Registration{Authority: client, Signer: signer, Credential: hop.RunCredential, Report: registrationReport(w)}.Keep(context.WithoutCancel(ctx)) defer func() { if !configured { unregister() @@ -167,6 +162,22 @@ func standInFront(ctx context.Context, config *adapters.Configuration) (Clean, e // serveHTTP listens on the hop's http endpoint, as the run allocated it, with // the hop's HTTP face. +// registrationReport logs each change of the registration's outcome: the +// published key's id (never the seed, never the private key) and when it +// lapses, or why it is not published. The id and the reason lead each line, +// in no label the run's log redaction reads as a credential's: a line naming +// "authority" and "key" before its first "=" or ":" has everything after it +// masked once the run forwards it, which hid the key id as "admission=****". +func registrationReport(w *wool.Wool) func(*authorityv1.PublicKey, time.Time, error) { + return func(key *authorityv1.PublicKey, expires time.Time, err error) { + if err != nil { + w.Warn("hop admission not published, so modules behind this hop admit no call; retrying: " + err.Error()) + return + } + w.Info(fmt.Sprintf("hop admission %s published, lapsing at %s unless renewed", key.GetKeyId(), expires.UTC().Format(time.RFC3339))) + } +} + func serveHTTP(ctx context.Context, face http.Handler) (func(), error) { instance, err := codefly.For(ctx).API(standards.HTTP).ResolveNetworkInstance() if err != nil { diff --git a/modules/authority/services/hop/code/work_test.go b/modules/authority/services/hop/code/work_test.go index 2c8988e8..574d3ccc 100644 --- a/modules/authority/services/hop/code/work_test.go +++ b/modules/authority/services/hop/code/work_test.go @@ -2,11 +2,16 @@ package main import ( "context" + "errors" "net" "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/stamp" "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/wool" "hop/pkg/adapters" ) @@ -79,3 +84,55 @@ func TestHopStartupRefusesUnprotectedUpstreamTransport(t *testing.T) { }) } } + +// logged keeps what a wool logger is told. +type logged struct{ lines []*wool.Log } + +func (l *logged) Process(line *wool.Log) { l.lines = append(l.lines, line) } + +// forwarded is a hop line as the run shows it: the run forwards the hop's +// output through its own logger, which redacts any line whose label (the +// text before its first "=" or ":") reads as a credential's. +func forwarded(t *testing.T, line *wool.Log) string { + t.Helper() + run := &logged{} + wool.Get(context.Background()).WithLogger(run).Info(line.String()) + if len(run.lines) != 1 { + t.Fatalf("the run logged %d lines", len(run.lines)) + } + return run.lines[0].Message +} + +// Review #28 F4: the registration log names the published key by its id, and +// a refusal by its reason, and both survive the run's redaction; they never +// name the seed. "the authority publishes this hop's admission key +// admission=hop-…" reached the run as "admission=****". +func TestTheRegistrationLogNamesTheKeyIDThroughTheRunsRedaction(t *testing.T) { + hop := &logged{} + report := registrationReport(wool.Get(context.Background()).In("hop").WithLogger(hop)) + seed := strings.Repeat("ab", 32) + signer, err := stamp.NewSigner(seed) + if err != nil { + t.Fatal(err) + } + id, public := signer.Admission() + lapses := time.Date(2026, 10, 26, 19, 30, 0, 0, time.UTC) + report(&authorityv1.PublicKey{KeyId: id, PublicKey: public}, lapses, nil) + report(nil, time.Time{}, errors.New("rpc error: code = PermissionDenied desc = the registration proof is stale")) + if len(hop.lines) != 2 { + t.Fatalf("the hop logged %d lines", len(hop.lines)) + } + published := forwarded(t, hop.lines[0]) + if !strings.Contains(published, id) || !strings.Contains(published, "2026-10-26T19:30:00Z") || strings.Contains(published, "****") { + t.Fatalf("the run shows %q, not the key id %s and its lapse", published, id) + } + refused := forwarded(t, hop.lines[1]) + if !strings.Contains(refused, "the registration proof is stale") || strings.Contains(refused, "****") { + t.Fatalf("the run shows %q, not the refusal's reason", refused) + } + for _, line := range hop.lines { + if strings.Contains(line.String(), seed) || strings.Contains(line.String(), signer.PublicKey()) { + t.Fatalf("the hop logged key material: %s", line.String()) + } + } +} diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index ff78cb89..9b754732 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -7,7 +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-client/go v0.0.0-20261026204838-dc49cde52d89 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 diff --git a/modules/authority/services/local/code/go.sum b/modules/authority/services/local/code/go.sum index daa295ed..fe8ad7b7 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -38,8 +38,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-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/authority/libraries/authority-client/go v0.0.0-20261026204838-dc49cde52d89 h1:jLNpRN8n+Mo3CKjeQnXzGIVRrqZ7USZXjgjFq+svPdE= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026204838-dc49cde52d89/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= diff --git a/modules/authority/services/local/code/pkg/business/issuer/admission.go b/modules/authority/services/local/code/pkg/business/issuer/admission.go index 3bea01f6..3535db92 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/admission.go +++ b/modules/authority/services/local/code/pkg/business/issuer/admission.go @@ -24,6 +24,15 @@ const AdmissionLease = 2 * time.Minute // and a few more absorb rotations closer together than one lease. const maxAdmissionKeys = 8 +// maxRegistrationNonces bounds the registration proofs remembered at once. +// Only the attested hop reaches the reservation, and it registers a few times +// a minute; past the bound a registration waits for a proof to age out. +const maxRegistrationNonces = 1024 + +// nonceRetention is how long an accepted registration proof's nonce is +// remembered: as long as the proof could still be judged fresh. +const nonceRetention = stamp.RegistrationWindow + stamp.Skew + // HopModule and HopService name the one workload whose admission keys this // authority publishes: the hop service of the authority module. const ( @@ -37,10 +46,13 @@ type admission struct { } // 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) { +// with, attesting that the caller is the hop in this host's trust domain and +// that it holds the key, and publishes it in Keys until the answered time; +// registering it again renews it. Any other workload is refused by name, and +// so is a registration whose proof (stamp.CheckRegistration) does not verify, +// is stale, or was already used: a captured credential alone registers +// nothing. Only the public half is ever presented. +func (i *Issuer) RegisterAdmissionKey(ctx context.Context, credential string, public []byte, proof string) (*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) @@ -49,18 +61,33 @@ func (i *Issuer) RegisterAdmissionKey(ctx context.Context, credential string, pu 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 host := i.trustDomain; host == "" || workload.TrustDomain != host { + return nil, time.Time{}, domain.Refuse("the caller is the hop of trust domain %q, not of this host's (%q): only this host's hop registers an admission key", + workload.TrustDomain, host) + } 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() + proved, err := stamp.CheckRegistration(proof, public, credential, i.state.Audience(), now) + if err != nil { + return nil, time.Time{}, domain.Refuse("%v", err) + } + key := &authorityv1.PublicKey{KeyId: stamp.KeyID(public), Algorithm: workcontext.Algorithm, PublicKey: append([]byte(nil), public...), Purpose: stamp.Purpose} until := now.Add(AdmissionLease) i.mu.Lock() defer i.mu.Unlock() i.lapse(now) + if _, replayed := i.nonces[proved.Nonce]; replayed { + return nil, time.Time{}, domain.Refuse("the registration proof was already used: sign a fresh one for each registration") + } + if len(i.nonces) >= maxRegistrationNonces { + return nil, time.Time{}, domain.NotNow("%d registration proofs are remembered; one must age out before another is accepted", maxRegistrationNonces) + } 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.nonces[proved.Nonce] = now.Add(nonceRetention) i.admission[key.KeyId] = admission{key: key, until: until} return key, until, nil } @@ -78,11 +105,17 @@ func (i *Issuer) admissionKeys() []*authorityv1.PublicKey { return out } -// lapse withdraws every registration past its lease. Called with mu held. +// lapse withdraws every registration past its lease, and forgets every +// registration proof too old to be judged fresh again. 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) } } + for nonce, until := range i.nonces { + if !now.Before(until) { + delete(i.nonces, nonce) + } + } } 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 index 9894be4e..29db3c21 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/admission_test.go +++ b/modules/authority/services/local/code/pkg/business/issuer/admission_test.go @@ -11,6 +11,8 @@ import ( "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + authorityv1 "gateway/pkg/gen/host/authority/v1" + "gateway/pkg/business/issuer" "gateway/pkg/business/state" "gateway/pkg/business/testkit" @@ -26,6 +28,18 @@ func hopSigner(t *testing.T, label string) *stamp.Signer { return signer } +// register registers signer's admission key as the hop presenting credential +// would at at: with a fresh proof signed by that key. +func register(t *testing.T, k *testkit.Kit, signer *stamp.Signer, credential string, at time.Time) (*authorityv1.PublicKey, time.Time, error) { + t.Helper() + proof, err := signer.ProveRegistration(credential, testkit.Audience, at) + if err != nil { + t.Fatal(err) + } + _, public := signer.Admission() + return k.Issuer.RegisterAdmissionKey(t.Context(), credential, public, proof) +} + func published(k *testkit.Kit) []string { var ids []string for _, key := range k.Issuer.Keys().GetAdmissionKeys() { @@ -44,8 +58,8 @@ func TestTheHopsRegisteredKeyIsPublishedInKeys(t *testing.T) { 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) + id, _ := hop.Admission() + key, until, err := register(t, k, hop, k.HopCredential(t), time.Now()) if err != nil { t.Fatal(err) } @@ -70,18 +84,19 @@ func TestTheHopsRegisteredKeyIsPublishedInKeys(t *testing.T) { // 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) + impostor := hopSigner(t, "impostor") + _, public := impostor.Admission() + _, _, err := register(t, k, impostor, k.Credential(t, "documents"), time.Now()) 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) { + if _, _, err := register(t, k, impostor, "not-a-credential", time.Now()); !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 { + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public[:16], ""); err == nil { t.Fatal("a truncated key was registered") } } @@ -92,13 +107,13 @@ 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) + hop := hopSigner(t, "the-hop") + _, first, err := register(t, k, hop, k.HopCredential(t), clock) if err != nil { t.Fatal(err) } clock = clock.Add(issuer.AdmissionLease / 2) - _, renewed, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public) + _, renewed, err := register(t, k, hop, k.HopCredential(t), clock) if err != nil || !renewed.After(first) { t.Fatalf("the restart renewed to %s (was %s): %v", renewed, first, err) } @@ -115,10 +130,11 @@ 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 { + retiring, next := hopSigner(t, "retiring"), hopSigner(t, "next") + retiringID, _ := retiring.Admission() + nextID, _ := next.Admission() + for _, signer := range []*stamp.Signer{retiring, next} { + if _, _, err := register(t, k, signer, k.HopCredential(t), clock); err != nil { t.Fatal(err) } } @@ -128,7 +144,7 @@ func TestARotationOverlapsUntilTheRetiringKeyLapses(t *testing.T) { // 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 { + if _, _, err := register(t, k, next, k.HopCredential(t), clock); err != nil { t.Fatal(err) } } @@ -145,12 +161,12 @@ func TestARotationOverlapsUntilTheRetiringKeyLapses(t *testing.T) { // refused by name, while a registered key still renews. func TestThePublishedAdmissionKeysAreBounded(t *testing.T) { k := testkit.Bare(t) - var last []byte + var last *stamp.Signer for n := 0; ; n++ { - _, public := hopSigner(t, "key-"+string(rune('a'+n))).Admission() - _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public) + signer := hopSigner(t, "key-"+string(rune('a'+n))) + _, _, err := register(t, k, signer, k.HopCredential(t), time.Now()) if err == nil { - last = public + last = signer continue } if code, _ := domain.CodeOf(err); code != domain.Precondition || !strings.Contains(err.Error(), "already published") || n < 2 { @@ -158,7 +174,86 @@ func TestThePublishedAdmissionKeysAreBounded(t *testing.T) { } break } - if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), last); err != nil { + if _, _, err := register(t, k, last, k.HopCredential(t), time.Now()); err != nil { t.Fatalf("a registered key no longer renews at the bound: %v", err) } } + +// Review #28 F1: the workload credential is a bearer; a registration must also +// prove it holds the key. A captured credential with no proof, with a proof +// by another key, made for another credential or authority, stale, or +// replayed, registers nothing. +func TestARegistrationMustProveItHoldsTheKey(t *testing.T) { + k := testkit.Bare(t) + clock := time.Now() + issuer.SetClock(k.Issuer, func() time.Time { return clock }) + hop, thief := hopSigner(t, "the-hop"), hopSigner(t, "thief") + _, public := hop.Admission() + _, thiefPublic := thief.Admission() + credential := k.HopCredential(t) + proof := func(signer *stamp.Signer, credential, audience string, at time.Time) string { + p, err := signer.ProveRegistration(credential, audience, at) + if err != nil { + t.Fatal(err) + } + return p + } + for name, tc := range map[string]struct { + public []byte + proof string + want string + }{ + "no proof": {thiefPublic, "", "missing"}, + "the hop's proof, another key": {thiefPublic, proof(hop, credential, testkit.Audience, clock), "not signed by the key being registered"}, + "a proof for another credential": {public, proof(hop, k.HopCredential(t), testkit.Audience, clock), "another workload credential"}, + "a proof for another authority": {public, proof(hop, credential, "https://elsewhere.test", clock), "another authority"}, + "a stale proof": {public, proof(hop, credential, testkit.Audience, clock.Add(-stamp.RegistrationWindow)), "stale"}, + } { + _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), credential, tc.public, tc.proof) + if code, _ := domain.CodeOf(err); code != domain.Denied || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: %v, want a refusal naming %q", name, err, tc.want) + } + } + if ids := published(k); len(ids) != 0 { + t.Fatalf("a registration without proof of the key published %v", ids) + } + fresh := proof(hop, credential, testkit.Audience, clock) + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), credential, public, fresh); err != nil { + t.Fatal(err) + } + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), credential, public, fresh); err == nil || !strings.Contains(err.Error(), "already used") { + t.Fatalf("a replayed registration was accepted: %v", err) + } + // The nonce is remembered only as long as the proof could pass as fresh. + clock = clock.Add(stamp.RegistrationWindow + stamp.Skew) + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), credential, public, fresh); err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("a replay after the window was not refused as stale: %v", err) + } +} + +// Review #28 F2: the hop must be this host's, by trust domain as well as by +// module and service: a hop the anchor vouches for in another trust domain is +// refused by name. +func TestOnlyTheHopOfThisHostsTrustDomainRegisters(t *testing.T) { + k := testkit.Bare(t) + if got := k.State.TrustDomain(); got != testkit.HopWorkload().TrustDomain { + t.Fatalf("the host's trust domain is %q, not the run's %q", got, testkit.HopWorkload().TrustDomain) + } + hop := hopSigner(t, "the-hop") + issuer.SetTrustDomain(k.Issuer, "elsewhere.test") + _, _, err := register(t, k, hop, k.HopCredential(t), time.Now()) + if code, _ := domain.CodeOf(err); code != domain.Denied || !strings.Contains(err.Error(), `trust domain "local.test", not of this host's ("elsewhere.test")`) { + t.Fatalf("a hop of another trust domain registered: %v", err) + } + issuer.SetTrustDomain(k.Issuer, "") + if _, _, err := register(t, k, hop, k.HopCredential(t), time.Now()); err == nil { + t.Fatal("a host that knows no trust domain of its own accepted a hop") + } + if ids := published(k); len(ids) != 0 { + t.Fatalf("a hop of another trust domain published %v", ids) + } + issuer.SetTrustDomain(k.Issuer, k.State.TrustDomain()) + if _, _, err := register(t, k, hop, k.HopCredential(t), time.Now()); err != nil { + t.Fatalf("this host's hop was refused: %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 index 70429953..50fdc8c5 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/export_test.go +++ b/modules/authority/services/local/code/pkg/business/issuer/export_test.go @@ -4,3 +4,7 @@ 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 } + +// SetTrustDomain sets the host's trust domain a registering hop must be in, +// for a test of a hop the anchor vouches for in another. +func SetTrustDomain(i *Issuer, domain string) { i.trustDomain = domain } 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 3b9f5a0b..09cc62d2 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/issuer.go +++ b/modules/authority/services/local/code/pkg/business/issuer/issuer.go @@ -74,12 +74,18 @@ type Issuer struct { // admission are the hop admission keys registered and not yet lapsed, by // key id (RegisterAdmissionKey). admission map[string]admission + // nonces are the registration proofs accepted and still fresh, each until + // it is forgotten, so none is accepted twice. + nonces map[string]time.Time + // trustDomain is the host's: only a hop attested in it registers a key. + trustDomain string } // 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, now: time.Now, admission: map[string]admission{}} + return &Issuer{state: st, svc: st.Service(), issuer: issuer, keyID: keyID, public: public, now: time.Now, + admission: map[string]admission{}, nonces: map[string]time.Time{}, trustDomain: st.TrustDomain()} } // Config is the core configuration for one signing key: what state.New 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 0902cde7..d16c562f 100644 --- a/modules/authority/services/local/code/pkg/business/state/state.go +++ b/modules/authority/services/local/code/pkg/business/state/state.go @@ -213,6 +213,10 @@ type State struct { roles map[string]Role // org/name, as authored, the generated developer role included sessions map[string]Session deliveries map[string]*appliedDelivery // binding → verified record, including withdrawals + // trustDomain is the run's: the one trust domain the launcher's anchor + // declares its workloads under when the host starts, or empty when it + // declares none or several. + trustDomain string // pendingInstalls are fixture installations whose target was not composed // when the fixture loaded; each is performed when its target applies. pendingInstalls []FixtureInstallation @@ -246,7 +250,7 @@ func New(anchor native.Anchor, audience string, cfg authority.Config) (*State, e if err != nil { return nil, err } - return &State{store: store, svc: svc, attester: attester, audience: audience, + return &State{store: store, svc: svc, attester: attester, audience: audience, trustDomain: runTrustDomain(anchor), orgs: map[string]string{}, people: map[string]Person{}, byToken: map[string]string{}, roles: map[string]Role{}, sessions: map[string]Session{}, deliveries: map[string]*appliedDelivery{}, Approvals: &MemoryApprovals{}}, nil } @@ -710,6 +714,31 @@ func (s *State) GrantRole(g domain.RoleGrant) error { return s.write(func(tx authority.Tx) error { return tx.PutRoleGrant(g) }) } +// runTrustDomain is the one trust domain every workload anchor declares is +// in: a run's launcher vouches under its environment's host trust domain, so +// it is the host's. Empty when the anchor declares none or more than one. +func runTrustDomain(anchor native.Anchor) string { + domain := "" + for _, subject := range anchor.Subjects { + switch { + case subject.TrustDomain == "": + return "" + case domain == "": + domain = subject.TrustDomain + case subject.TrustDomain != domain: + return "" + } + } + return domain +} + +// TrustDomain is the host's trust domain, as the run's anchor declared it +// when the host started; empty when it named no single one. +func (s *State) TrustDomain() string { return s.trustDomain } + +// Audience is what this host attests workload credentials for. +func (s *State) Audience() string { return s.audience } + // 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. diff --git a/modules/authority/services/local/code/pkg/business/state/trust_domain_test.go b/modules/authority/services/local/code/pkg/business/state/trust_domain_test.go new file mode 100644 index 00000000..e303dd31 --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/state/trust_domain_test.go @@ -0,0 +1,50 @@ +package state_test + +import ( + "crypto/ed25519" + "crypto/rand" + "testing" + "time" + + "github.com/codefly-dev/core/workloadidentity" + "github.com/codefly-dev/core/workloadidentity/native" + + "gateway/pkg/business/issuer" + "gateway/pkg/business/state" + "gateway/pkg/business/testkit" +) + +// The host's trust domain is the run's: the one the launcher's anchor +// declares every workload under. An anchor naming several, or none, gives the +// host none, so no hop is ever matched against a guess. +func TestTheHostsTrustDomainIsTheOneTheAnchorDeclares(t *testing.T) { + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + build := func(domains ...string) string { + launcher, err := native.NewIssuer() + if err != nil { + t.Fatal(err) + } + for n, domain := range domains { + launcher.Declare(workloadidentity.Workload{TrustDomain: domain, Module: "m" + string(rune('a'+n)), Service: "api"}) + } + st, err := state.New(launcher.Anchor(), testkit.Audience, issuer.Config(testkit.Audience, "k", key, time.Now)) + if err != nil { + t.Fatal(err) + } + return st.TrustDomain() + } + for want, domains := range map[string][]string{ + "run.test": {"run.test", "run.test"}, + "": {"run.test", "elsewhere.test"}, + } { + if got := build(domains...); got != want { + t.Errorf("an anchor declaring %v gives the host trust domain %q, want %q", domains, got, want) + } + } + if got := build(); got != "" { + t.Errorf("an anchor declaring nothing gives the host trust domain %q", got) + } +} diff --git a/modules/authority/services/local/code/plugins/authority/plugin.go b/modules/authority/services/local/code/plugins/authority/plugin.go index d1e5af3c..476761ad 100644 --- a/modules/authority/services/local/code/plugins/authority/plugin.go +++ b/modules/authority/services/local/code/plugins/authority/plugin.go @@ -222,9 +222,10 @@ func (p *Plugin) Keys(context.Context, *authorityv1.KeysRequest) (*authorityv1.K } // RegisterAdmissionKey publishes the hop's admission key in Keys, for the hop -// alone, until its registration lapses. +// alone and only with its proof of holding the key, 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()) + key, until, err := p.Issuer.RegisterAdmissionKey(ctx, req.GetActor().GetCredential(), req.GetPublicKey(), req.GetProof()) if err != nil { return nil, Status(err) } diff --git a/modules/authority/services/local/code/plugins/installation/offers_test.go b/modules/authority/services/local/code/plugins/installation/offers_test.go index fcf271e4..85e12e88 100644 --- a/modules/authority/services/local/code/plugins/installation/offers_test.go +++ b/modules/authority/services/local/code/plugins/installation/offers_test.go @@ -2,6 +2,7 @@ package installation import ( "context" + "strings" "testing" "time" @@ -167,6 +168,28 @@ func TestOffersAreAnAdministratorsRead(t *testing.T) { } } +// Review #30 F4: administering one organization is no standing at another. +// Globex's administrator lists and reviews Globex's offers, and is refused +// Acme's by name, the read never reaching Acme's installations. +func TestAnotherOrganizationsAdministratorIsRefusedTheOffers(t *testing.T) { + k := testkit.New(t) + offers := &Offers{plugin: &Plugin{State: k.State}} + zoe := signedIn(t, k, "fixture-zoe") + if _, err := offers.ListOffers(zoe, &installationv1.ListOffersRequest{OrganizationId: "globex"}); err != nil { + t.Fatalf("Globex's administrator lists Globex's offers: %v", err) + } + if _, err := offers.ReviewOffer(zoe, &installationv1.ReviewRequest{OrganizationId: "globex", Target: "wiki"}); err != nil { + t.Fatalf("Globex's administrator reviews Globex's wiki: %v", err) + } + if _, err := offers.ListOffers(zoe, &installationv1.ListOffersRequest{OrganizationId: "acme"}); status.Code(err) != codes.PermissionDenied || !strings.Contains(err.Error(), "acme") { + t.Fatalf("Globex's administrator listed Acme's offers: %v", err) + } + review, err := offers.ReviewOffer(zoe, &installationv1.ReviewRequest{OrganizationId: "acme", Target: "wiki"}) + if status.Code(err) != codes.PermissionDenied || !strings.Contains(err.Error(), "acme") || review != nil { + t.Fatalf("Globex's administrator reviewed Acme's wiki: %v %v", review, err) + } +} + func mustCaller(t *testing.T, k *testkit.Kit, token string) authority.Person { t.Helper() sess, err := k.State.SignIn(token) 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 index 3124bc7d..088031dc 100644 --- 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 @@ -70,22 +70,28 @@ func TestUnderThePlaygroundsIdentityTheHopsRegisteredKeyAdmitsItsStamps(t *testi t.Fatalf("before the hop registered Keys publishes %v: %v", keys.GetAdmissionKeys(), err) } - // The hop registers, presenting its public half and its credential. + // The hop registers, presenting its public half and its credential, and + // proving with that key that it holds it. credential, err := os.ReadFile(projected) if err != nil { t.Fatal(err) } - id, public := signer.Admission() - register := func(credential string, public []byte) (*authorityv1.RegisterAdmissionKeyResponse, error) { + id, _ := signer.Admission() + register := func(credential string, by *stamp.Signer) (*authorityv1.RegisterAdmissionKeyResponse, error) { + proof, err := by.ProveRegistration(credential, testkit.Audience, time.Now()) + if err != nil { + t.Fatal(err) + } + _, public := by.Admission() return client.RegisterAdmissionKey(ctx, &authorityv1.RegisterAdmissionKeyRequest{ - Actor: &authorityv1.WorkloadCredential{Credential: credential}, PublicKey: public}) + Actor: &authorityv1.WorkloadCredential{Credential: credential}, PublicKey: public, Proof: proof}) } - first, err := register(string(credential), public) + first, err := register(string(credential), signer) 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) + again, err := register(string(credential), signer) if err != nil || again.GetExpiresAtUnix() < first.GetExpiresAtUnix() { t.Fatalf("the hop's re-registration: %v %v", again, err) } @@ -132,10 +138,23 @@ func TestUnderThePlaygroundsIdentityTheHopsRegisteredKeyAdmitsItsStamps(t *testi // 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 || + if _, err := register(k.Credential(t, "documents"), impostor); 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) } + // Review #28 F1: so is the hop's own credential, captured, presenting + // another key without that key's proof, or replaying the hop's proof. + hopProof, err := signer.ProveRegistration(string(credential), testkit.Audience, time.Now()) + if err != nil { + t.Fatal(err) + } + for name, proof := range map[string]string{"no proof": "", "the hop's proof": hopProof} { + if _, err := client.RegisterAdmissionKey(ctx, &authorityv1.RegisterAdmissionKeyRequest{ + Actor: &authorityv1.WorkloadCredential{Credential: string(credential)}, PublicKey: impostorKey, Proof: proof}); status.Code(err) != codes.PermissionDenied || + !strings.Contains(err.Error(), "registration proof") { + t.Fatalf("a captured hop credential with %s registered another key: %v", name, err) + } + } if keys, _ := client.Keys(ctx, &authorityv1.KeysRequest{}); len(keys.GetAdmissionKeys()) != 1 { t.Fatalf("a refused registration changed Keys: %v", keys.GetAdmissionKeys()) } From 4ac2e19ca2861870d296816171d55e94bb9a93fe Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:27:18 -0400 Subject: [PATCH 3/5] fix(authority): say what a registration proof proves (authority 0.3.4) Review of codefly-dev/authority#32 (B1): the proof is signed by the key the request registers, which the registrant chooses, and the native credential binds no key; so it proves possession of that key, not that the registrant is the hop. A thief holding the hop's credential can sign with a key of its own. The interface now states the guarantee the authority gives: one admission key is pinned per hop instance (the instance the workload credential names); the first registration for an instance binds its key until the lease lapses, the same key renews, any other key for that instance is refused by name. It states what remains open: a captured credential that registers before that instance's own first registration, or after an authority restart before the hop's next renewal. The proof stays for replay, audience, freshness and credential binding, and stamp's doc says so. Comments only on the wire. Regenerated through codefly v0.1.215 (generate proto, generate client --no-facade); the no-proxy gate's manifest records the regenerated Go files. Co-Authored-By: Claude Opus 5.5 --- .../contract/catalog.codefly.json | 2 +- .../authority-client/contract/contract.binpb | Bin 398919 -> 400108 bytes .../go/gen/host/authority/v1/authority.pb.go | 4 +- .../host/authority/v1/authority_grpc.pb.go | 80 +++++++++++++----- .../authority-client/go/stamp/registration.go | 11 ++- .../authority-client/library.codefly.yaml | 2 +- .../host/authority/v1/authority_pb2_grpc.py | 40 ++++++--- .../src/gen/host/authority/v1/authority_pb.ts | 44 +++++++--- .../pkg/gen/host/authority/v1/authority.pb.go | 4 +- .../host/authority/v1/authority_grpc.pb.go | 80 +++++++++++++----- .../authorityv1connect/authority.connect.go | 80 +++++++++++++----- .../proto/host/authority/v1/authority.proto | 44 +++++++--- tools/noproxygate/generated.json | 10 +-- 13 files changed, 287 insertions(+), 114 deletions(-) diff --git a/libraries/authority-client/contract/catalog.codefly.json b/libraries/authority-client/contract/catalog.codefly.json index 248e8308..6a565c6f 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:3f768e844a891e1554b597e586afb39720a2bd6cdb89d99da803e2d54db4204b", + "digest": "sha256:579bc11f4c82b90516565027aea11c4738ae565dd2502ee32209d28f8c4bd197", "services": [ { "name": "LocalService", diff --git a/libraries/authority-client/contract/contract.binpb b/libraries/authority-client/contract/contract.binpb index 98313b30bf9da25e464669bb61a1fc9cd5de6677..ed82604588dea3a131a2553ff143d3785c69f2f9 100644 GIT binary patch delta 7197 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 delta 6061 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 P-~`S<;lNfozVrV9PsEH# 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 9a982bbc..e760d7fd 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 @@ -1138,7 +1138,9 @@ type RegisterAdmissionKeyRequest struct { // of actor's credential, the hex public key, a fresh 32-byte hex nonce and // the Unix second it was signed. The authority refuses a proof that does // not verify, names another credential, key or audience, is older than 30 - // seconds or ahead of its clock, or repeats a nonce it has seen. + // seconds or ahead of its clock, or repeats a nonce it has seen. It proves + // possession of the key being registered, which the registrant chose; that + // the registrant is the hop rests on actor and the per-instance pin. Proof string `protobuf:"bytes,3,opt,name=proof,proto3" json:"proof,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache 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 ba36f128..692f79c0 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 @@ -61,17 +61,35 @@ type AuthorityServiceClient interface { // 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, in the - // authority's own trust domain; any other workload is refused by name. The - // hop also proves it holds the key: the request carries a fresh proof signed - // by the admission key itself over the credential, the key and the - // authority's audience, and a stale, replayed or unsigned registration is - // refused, so a captured credential alone registers nothing. 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. + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // 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 key (its + // instance still renewing) and the new key (a new instance's) 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 @@ -279,17 +297,35 @@ type AuthorityServiceServer interface { // 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, in the - // authority's own trust domain; any other workload is refused by name. The - // hop also proves it holds the key: the request carries a fresh proof signed - // by the admission key itself over the credential, the key and the - // authority's audience, and a stale, replayed or unsigned registration is - // refused, so a captured credential alone registers nothing. 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. + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // 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 key (its + // instance still renewing) and the new key (a new instance's) 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 diff --git a/libraries/authority-client/go/stamp/registration.go b/libraries/authority-client/go/stamp/registration.go index acf2b490..7e8a7c4f 100644 --- a/libraries/authority-client/go/stamp/registration.go +++ b/libraries/authority-client/go/stamp/registration.go @@ -32,9 +32,12 @@ type Registration struct { // ProveRegistration is the hop's proof that it holds the admission key it // registers: the admission key itself signs the authority's audience, the // digest of the workload credential presented with it, the public key and a -// fresh nonce and time. A captured credential alone therefore registers -// nothing, and a proof made for one credential, key or authority is refused -// with any other. +// fresh nonce and time. A proof made for one credential, key or authority is +// refused with any other, and an authority refuses it once used or stale. It +// proves possession of a key the registrant chose, not that the registrant is +// the hop: whoever presents the credential could sign with a key of its own. +// What stops a captured credential from replacing the hop's key is the +// authority pinning one key per hop instance (RegisterAdmissionKey). func (s *Signer) ProveRegistration(credential, audience string, now time.Time) (string, error) { if s == nil || len(s.key) != ed25519.PrivateKeySize || credential == "" || audience == "" { return "", errors.New("hop: a registration proof needs the admission key, the workload credential and the authority's audience") @@ -69,7 +72,7 @@ func CheckRegistration(proof string, public ed25519.PublicKey, credential, audie } parts := strings.Split(proof, ".") if len(parts) != 2 { - return refuse("is missing or not .: the hop proves it holds the key it registers") + return refuse("is missing or not .: a registration is signed by the key it registers") } payload, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil || base64.RawURLEncoding.EncodeToString(payload) != parts[0] { diff --git a/libraries/authority-client/library.codefly.yaml b/libraries/authority-client/library.codefly.yaml index 0576d35d..e0f9ac3e 100644 --- a/libraries/authority-client/library.codefly.yaml +++ b/libraries/authority-client/library.codefly.yaml @@ -20,7 +20,7 @@ sources: module: authority service: local endpoint: grpc - contract-digest: sha256:3f768e844a891e1554b597e586afb39720a2bd6cdb89d99da803e2d54db4204b + contract-digest: sha256:579bc11f4c82b90516565027aea11c4738ae565dd2502ee32209d28f8c4bd197 services: - LocalService generated-by: 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 b1d8135b..a2f2bdbe 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 @@ -128,17 +128,35 @@ 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, in the - authority's own trust domain; any other workload is refused by name. The - hop also proves it holds the key: the request carries a fresh proof signed - by the admission key itself over the credential, the key and the - authority's audience, and a stale, replayed or unsigned registration is - refused, so a captured credential alone registers nothing. 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. + authority's own trust domain; any other workload is refused by name. + Only the public half travels: the hop's seed never leaves it. + + The authority pins one admission key per hop instance (the instance its + workload credential names): the first registration for an instance binds + that key until its lease lapses, the same key renews it, and any other key + for that instance is refused by name until then. A new key comes from a + new hop instance, or from the same one once its previous key has lapsed. + So a captured hop credential cannot replace or add a key for an instance + that has registered. What it does not stop: a holder of a captured + credential who registers before that instance's own first registration + (the hop registers at boot), or in the window after an authority restart + before the hop's next renewal, pins its own key for that instance; the + hop's registration is then refused by name, and its log says so. A + credential binding the key at issuance is what would close that window. + + Each request also carries a proof signed by the key it registers (see + RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + key's private half and binds the request to its credential, the + authority's audience and a fresh, single-use nonce, so a registration + cannot be replayed, retargeted or reused for another credential; it does + not prove the registrant is the hop, since a registrant chooses its key. + + 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 key (its + instance still renewing) and the new key (a new instance's) are both + registered, and both published. """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') 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 528fbdf0..86649cc2 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 @@ -635,7 +635,9 @@ export type RegisterAdmissionKeyRequest = Message<"host.authority.v1.RegisterAdm * of actor's credential, the hex public key, a fresh 32-byte hex nonce and * the Unix second it was signed. The authority refuses a proof that does * not verify, names another credential, key or audience, is older than 30 - * seconds or ahead of its clock, or repeats a nonce it has seen. + * seconds or ahead of its clock, or repeats a nonce it has seen. It proves + * possession of the key being registered, which the registrant chose; that + * the registrant is the hop rests on actor and the per-instance pin. * * @generated from field: string proof = 3; */ @@ -1059,17 +1061,35 @@ export const AuthorityService: GenService<{ * 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, in the - * authority's own trust domain; any other workload is refused by name. The - * hop also proves it holds the key: the request carries a fresh proof signed - * by the admission key itself over the credential, the key and the - * authority's audience, and a stale, replayed or unsigned registration is - * refused, so a captured credential alone registers nothing. 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. + * authority's own trust domain; any other workload is refused by name. + * Only the public half travels: the hop's seed never leaves it. + * + * The authority pins one admission key per hop instance (the instance its + * workload credential names): the first registration for an instance binds + * that key until its lease lapses, the same key renews it, and any other key + * for that instance is refused by name until then. A new key comes from a + * new hop instance, or from the same one once its previous key has lapsed. + * So a captured hop credential cannot replace or add a key for an instance + * that has registered. What it does not stop: a holder of a captured + * credential who registers before that instance's own first registration + * (the hop registers at boot), or in the window after an authority restart + * before the hop's next renewal, pins its own key for that instance; the + * hop's registration is then refused by name, and its log says so. A + * credential binding the key at issuance is what would close that window. + * + * Each request also carries a proof signed by the key it registers (see + * RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + * key's private half and binds the request to its credential, the + * authority's audience and a fresh, single-use nonce, so a registration + * cannot be replayed, retargeted or reused for another credential; it does + * not prove the registrant is the hop, since a registrant chooses its key. + * + * 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 key (its + * instance still renewing) and the new key (a new instance's) are both + * registered, and both published. * * @generated from rpc host.authority.v1.AuthorityService.RegisterAdmissionKey */ 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 eaae857a..da2a02ee 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 @@ -1138,7 +1138,9 @@ type RegisterAdmissionKeyRequest struct { // of actor's credential, the hex public key, a fresh 32-byte hex nonce and // the Unix second it was signed. The authority refuses a proof that does // not verify, names another credential, key or audience, is older than 30 - // seconds or ahead of its clock, or repeats a nonce it has seen. + // seconds or ahead of its clock, or repeats a nonce it has seen. It proves + // possession of the key being registered, which the registrant chose; that + // the registrant is the hop rests on actor and the per-instance pin. Proof string `protobuf:"bytes,3,opt,name=proof,proto3" json:"proof,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache 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 ba36f128..692f79c0 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 @@ -61,17 +61,35 @@ type AuthorityServiceClient interface { // 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, in the - // authority's own trust domain; any other workload is refused by name. The - // hop also proves it holds the key: the request carries a fresh proof signed - // by the admission key itself over the credential, the key and the - // authority's audience, and a stale, replayed or unsigned registration is - // refused, so a captured credential alone registers nothing. 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. + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // 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 key (its + // instance still renewing) and the new key (a new instance's) 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 @@ -279,17 +297,35 @@ type AuthorityServiceServer interface { // 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, in the - // authority's own trust domain; any other workload is refused by name. The - // hop also proves it holds the key: the request carries a fresh proof signed - // by the admission key itself over the credential, the key and the - // authority's audience, and a stale, replayed or unsigned registration is - // refused, so a captured credential alone registers nothing. 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. + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // 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 key (its + // instance still renewing) and the new key (a new instance's) 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 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 96eb73af..233f08b1 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 @@ -89,17 +89,35 @@ type AuthorityServiceClient interface { // 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, in the - // authority's own trust domain; any other workload is refused by name. The - // hop also proves it holds the key: the request carries a fresh proof signed - // by the admission key itself over the credential, the key and the - // authority's audience, and a stale, replayed or unsigned registration is - // refused, so a captured credential alone registers nothing. 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. + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // 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 key (its + // instance still renewing) and the new key (a new instance's) 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 @@ -335,17 +353,35 @@ type AuthorityServiceHandler interface { // 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, in the - // authority's own trust domain; any other workload is refused by name. The - // hop also proves it holds the key: the request carries a fresh proof signed - // by the admission key itself over the credential, the key and the - // authority's audience, and a stale, replayed or unsigned registration is - // refused, so a captured credential alone registers nothing. 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. + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // 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 key (its + // instance still renewing) and the new key (a new instance's) 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 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 c1907b03..1cd66fd0 100644 --- a/modules/authority/services/local/proto/host/authority/v1/authority.proto +++ b/modules/authority/services/local/proto/host/authority/v1/authority.proto @@ -33,17 +33,35 @@ service AuthorityService { // 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, in the - // authority's own trust domain; any other workload is refused by name. The - // hop also proves it holds the key: the request carries a fresh proof signed - // by the admission key itself over the credential, the key and the - // authority's audience, and a stale, replayed or unsigned registration is - // refused, so a captured credential alone registers nothing. 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. + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // 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 key (its + // instance still renewing) and the new key (a new instance's) 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 @@ -317,7 +335,9 @@ message RegisterAdmissionKeyRequest { // of actor's credential, the hex public key, a fresh 32-byte hex nonce and // the Unix second it was signed. The authority refuses a proof that does // not verify, names another credential, key or audience, is older than 30 - // seconds or ahead of its clock, or repeats a nonce it has seen. + // seconds or ahead of its clock, or repeats a nonce it has seen. It proves + // possession of the key being registered, which the registrant chose; that + // the registrant is the hop rests on actor and the per-instance pin. string proof = 3; } diff --git a/tools/noproxygate/generated.json b/tools/noproxygate/generated.json index 97be4f0a..312805e6 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": "d3a1f7c471d19c69260ab1436e9b464c872907c40e4ddd27cf50c1993071cf37", - "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "43fe2868d420ce5f96c6c7986d142515a720ffd50793c1a43fe7cadc223b835e", + "libraries/authority-client/go/gen/host/authority/v1/authority.pb.go": "65c671c8c1cc2e4ec1aa79e840c04a51498bb883e37cef5638465803e6a6f6ca", + "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "8423702eea66abf48b9feb82778bd80f0669102156588104f16bad8734107cfc", "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": "81ab23cd032fe5a43e25e33dd9991bbb4d5e89a474936525e75029929eb8e3ea", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go": "58ab0dd9a10389e6f9b9b420a5297a1b13de5837b4494297d36f11b85a6487ba", "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": "43fe2868d420ce5f96c6c7986d142515a720ffd50793c1a43fe7cadc223b835e", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go": "7136aac3d033ad0359a04b90413d86d12bb1f0e9d889616b45241e538d9f10e4", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go": "8423702eea66abf48b9feb82778bd80f0669102156588104f16bad8734107cfc", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go": "5d0260cc1c302f6a42117706c3354efa8f4299846f78550d17685756da580a5e", "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", From 7b6569e1f28510cd72d77bb434075fae5fbc2392 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:28:01 -0400 Subject: [PATCH 4/5] fix(stamp): the mutated-proof test always mutates the signature TestSignedCallBindingRejectsMutationAndExpiredOrAmbiguousProof replaced the proof's last two characters with "AA". The signature covers a random nonce, so about one run in 256 the signature already ended in "AA": the "mutated" proof was the signed one and the test failed (5 of 6 batches of 20 failed locally). It now changes the signature's first character to a different valid one. Found while gating #32; pre-existing on main since e1966db. Co-Authored-By: Claude Opus 5.5 --- libraries/authority-client/go/stamp/call_test.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/libraries/authority-client/go/stamp/call_test.go b/libraries/authority-client/go/stamp/call_test.go index 87ddc44f..06ee8de4 100644 --- a/libraries/authority-client/go/stamp/call_test.go +++ b/libraries/authority-client/go/stamp/call_test.go @@ -29,7 +29,9 @@ func TestSignedCallBindingRejectsMutationAndExpiredOrAmbiguousProof(t *testing.T t.Fatal(err) } for _, mutation := range []func(metadata.MD){ - func(m metadata.MD) { m.Set("x-codefly-hop-proof", proof[:len(proof)-2]+"AA") }, + // The signature's first character, changed: always a different + // signature (the last two could already read "AA", 1 in 256). + func(m metadata.MD) { m.Set("x-codefly-hop-proof", tamperSignature(proof)) }, 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[:])) }, @@ -127,3 +129,14 @@ func TestAdmissionIsTheSignersStablePublishedEntry(t *testing.T) { t.Fatal("two keys share a key id") } } + +// tamperSignature changes one character of a proof's signature to another +// valid one, so the proof always differs from the signed one. +func tamperSignature(proof string) string { + at := strings.Index(proof, ".") + 1 + swap := byte('A') + if proof[at] == swap { + swap = 'B' + } + return proof[:at] + string(swap) + proof[at+1:] +} From aa22af4e63a15a011ea4a88fe4cd44eef65d5a9f Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:33:27 -0400 Subject: [PATCH 5/5] fix(authority): pin one admission key per hop instance Review of codefly-dev/authority#32 (B1): a registration's proof is signed by the key being registered, which the registrant picks, and the native credential binds no key, so a thief holding the hop's 600 s credential could sign with a key of its own and register it. The local authority now pins one admission key per hop instance (the instance every native credential names): the first registration for an instance binds its key until its lease lapses, the same key renews, and any other key for that instance is refused by name (bounded at 64 instances). A captured credential therefore cannot replace or add a key for an instance that has registered; a new instance (restart, rotation, replica) registers its own. What remains, stated in the docs and a test: a captured credential used before the instance's own first registration (the hop registers at boot), or after an authority restart before the hop's next renewal, pins its key instead, and the hop's registration is refused by name in its log. Hop library and local authority pin authority-client at 7b6569e. READMEs and the hop library doc state the guarantee and its limit exactly. Co-Authored-By: Claude Opus 5.5 --- README.md | 33 +++++-- libraries/hop/README.md | 19 +++- libraries/hop/go/go.mod | 2 +- libraries/hop/go/go.sum | 4 +- libraries/hop/go/register.go | 8 +- modules/authority/services/local/code/go.mod | 2 +- modules/authority/services/local/code/go.sum | 4 +- .../code/pkg/business/issuer/admission.go | 52 ++++++++-- .../pkg/business/issuer/admission_test.go | 99 ++++++++++++++++--- .../local/code/pkg/business/issuer/issuer.go | 5 +- .../code/pkg/business/testkit/testkit.go | 12 ++- .../local/code/plugins/authority/plugin.go | 4 +- .../registry_test/hop_admission_test.go | 6 ++ 13 files changed, 204 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index dd4115d7..b80cbef4 100644 --- a/README.md +++ b/README.md @@ -1044,16 +1044,31 @@ 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. -`codefly.dev/authority@0.3.4` makes the registration prove possession of the -key: `RegisterAdmissionKeyRequest.proof` is an Ed25519 signature by the -admission key itself over the authority's audience, the SHA-256 of the -presented credential, the key and a fresh nonce and second -(`stamp.ProveRegistration`, `stamp.CheckRegistration`). The authority refuses a -missing, mismatched, stale (over 30 seconds) or replayed proof, and a hop -attested in any trust domain but its own (the one the run's anchor declares its -workloads under), so a captured hop credential alone registers nothing. The +`codefly.dev/authority@0.3.4` hardens the registration. The authority pins one +admission key per hop instance (the instance the hop's workload credential +names): the first registration for an instance binds its key until the lease +lapses, the same key renews, and any other key for that instance is refused by +name. So a captured hop credential cannot replace or add a key for an instance +that has registered. It is not a key binding: a holder of a captured credential +who registers before that instance's first registration (the hop registers at +boot), or after an authority restart (pins are in memory) and before the hop's +next renewal, pins its own key, and the hop's registration is then refused by +name in its log. Closing that window needs the launcher to bind the key in the +credential at issuance. Each registration also carries +`RegisterAdmissionKeyRequest.proof`, an Ed25519 signature by the key it +registers over the authority's audience, the SHA-256 of the presented +credential, the key and a fresh nonce and second (`stamp.ProveRegistration`, +`stamp.CheckRegistration`): it proves possession of that key and stops a +replayed, retargeted, stale (over 30 seconds) or credential-swapped +registration, but since the registrant chooses the key it does not by itself +prove the registrant is the hop. A hop attested in any trust domain but the +host's (the one the run's anchor declares its workloads under) is refused. The field is additive on the wire; an authority at 0.3.4 refuses a 0.3.3 hop's -unproved registration, so the hop and the authority move together. +unproved registration, so the hop and the authority move together. A key +rotation is a new hop instance with the new seed, overlapping the retiring one +until its lease lapses; a new seed under the same instance (a hop restarted +within one run keeps its instance) registers once the previous key's lease +lapses, within two minutes of the hop's retries. 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 diff --git a/libraries/hop/README.md b/libraries/hop/README.md index f5fb4f1a..5cf1a2c9 100644 --- a/libraries/hop/README.md +++ b/libraries/hop/README.md @@ -204,11 +204,20 @@ 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. Since -`codefly.dev/authority@0.3.4` each registration also carries a fresh proof -signed by the admission key over that credential, the key and the audience the -credential names (`stamp.ProveRegistration`), so a captured credential alone -registers nothing; the authority refuses a stale or replayed proof, and a hop -outside its own trust domain. The hop logs the published key by its id. A registration +`codefly.dev/authority@0.3.4` the authority pins one key per hop instance: +this hop's first registration (at boot) binds its key to its instance, the same +key renews, and a captured credential cannot replace or add a key for that +instance while the registration holds. A captured credential used before the +instance's first registration, or after an authority restart before the next +renewal, would pin its own key instead; the hop's registration is then refused +by name and logged. Each registration also carries a fresh proof signed by the +admission key over that credential, the key and the audience the credential +names (`stamp.ProveRegistration`); the authority refuses a stale, replayed or +retargeted proof, and a hop outside its own trust domain. The proof shows +possession of the key registered, not that the registrant is the hop. A rotated +seed is registered by a new hop instance; under the same instance (a hop +restarted within one run keeps its instance) it is refused until the previous +key's lease lapses, at most `AdmissionLease`, and the hop's retries then register it. The hop logs the published key by its id. 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 diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index f15168b5..3291c670 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -3,7 +3,7 @@ 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-20261026204838-dc49cde52d89 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026232801-7b6569e1f285 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 diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index 278b7882..35a5dc1f 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-20261026204838-dc49cde52d89 h1:jLNpRN8n+Mo3CKjeQnXzGIVRrqZ7USZXjgjFq+svPdE= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026204838-dc49cde52d89/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026232801-7b6569e1f285 h1:evq/BoytmbddnA/r7thGb25PMrveFZp0HP0OiVXyO9I= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026232801-7b6569e1f285/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= diff --git a/libraries/hop/go/register.go b/libraries/hop/go/register.go index 4ca9482a..48153413 100644 --- a/libraries/hop/go/register.go +++ b/libraries/hop/go/register.go @@ -27,8 +27,12 @@ const registerRetry = 5 * time.Second // which publishes it in Keys for every module behind the hop: the hop presents // the public half with its workload credential, never its seed, and a fresh // proof signed by the admission key over that credential, the key and the -// authority's audience (stamp.ProveRegistration), so the credential alone -// registers nothing. A registration +// authority's audience (stamp.ProveRegistration). The authority pins one key +// per hop instance: this hop's first registration binds its key to its +// instance, and a captured credential cannot replace or add a key for it +// while that registration holds. Keep registers at boot, before any request +// is served, because a first registration by another key would win; the +// refusal is reported by name. 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 { diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 9b754732..7941ee79 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -7,7 +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-20261026204838-dc49cde52d89 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026232801-7b6569e1f285 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 diff --git a/modules/authority/services/local/code/go.sum b/modules/authority/services/local/code/go.sum index fe8ad7b7..ea8e9d0e 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -38,8 +38,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-20261026204838-dc49cde52d89 h1:jLNpRN8n+Mo3CKjeQnXzGIVRrqZ7USZXjgjFq+svPdE= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026204838-dc49cde52d89/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026232801-7b6569e1f285 h1:evq/BoytmbddnA/r7thGb25PMrveFZp0HP0OiVXyO9I= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026232801-7b6569e1f285/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= diff --git a/modules/authority/services/local/code/pkg/business/issuer/admission.go b/modules/authority/services/local/code/pkg/business/issuer/admission.go index 3535db92..e4d40e05 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/admission.go +++ b/modules/authority/services/local/code/pkg/business/issuer/admission.go @@ -29,6 +29,10 @@ const maxAdmissionKeys = 8 // a minute; past the bound a registration waits for a proof to age out. const maxRegistrationNonces = 1024 +// maxHopInstances bounds the hop instances pinned at once: one per running hop +// replica, each lapsing with its key's lease. +const maxHopInstances = 64 + // nonceRetention is how long an accepted registration proof's nonce is // remembered: as long as the proof could still be judged fresh. const nonceRetention = stamp.RegistrationWindow + stamp.Skew @@ -45,13 +49,28 @@ type admission struct { until time.Time } +// pin is the one admission key a hop instance registered, and until when +// that instance's registration holds it. +type pin struct { + keyID string + until time.Time +} + // RegisterAdmissionKey records the public key the hop signs call admissions -// with, attesting that the caller is the hop in this host's trust domain and -// that it holds the key, and publishes it in Keys until the answered time; -// registering it again renews it. Any other workload is refused by name, and -// so is a registration whose proof (stamp.CheckRegistration) does not verify, -// is stale, or was already used: a captured credential alone registers -// nothing. Only the public half is ever presented. +// with and publishes it in Keys until the answered time; registering it again +// renews it. The caller must be the hop of this host's trust domain (any +// other workload is refused by name) and must present a fresh, unused proof +// signed by the key it registers (stamp.CheckRegistration). +// +// One key is pinned per hop instance, the instance the credential names: the +// first registration for an instance binds its key until that registration's +// lease lapses, the same key renews it, and another key for that instance is +// refused by name until then. The proof proves possession of a key the +// registrant chose, so it is the pin that keeps a captured hop credential +// from replacing or adding a key for an instance that has registered. It +// does not keep one from registering first: before that instance's own first +// registration (the hop registers at boot), or after a restart of this +// authority (pins are in memory) and before the hop's next renewal. func (i *Issuer) RegisterAdmissionKey(ctx context.Context, credential string, public []byte, proof string) (*authorityv1.PublicKey, time.Time, error) { workload, err := i.state.AttestWorkload(ctx, credential) if err != nil { @@ -61,6 +80,9 @@ func (i *Issuer) RegisterAdmissionKey(ctx context.Context, credential string, pu 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 workload.Instance == "" { + return nil, time.Time{}, domain.Refuse("the hop's credential names no instance, so no admission key can be pinned to it") + } if host := i.trustDomain; host == "" || workload.TrustDomain != host { return nil, time.Time{}, domain.Refuse("the caller is the hop of trust domain %q, not of this host's (%q): only this host's hop registers an admission key", workload.TrustDomain, host) @@ -78,6 +100,10 @@ func (i *Issuer) RegisterAdmissionKey(ctx context.Context, credential string, pu i.mu.Lock() defer i.mu.Unlock() i.lapse(now) + if p, pinned := i.pins[workload.Instance]; pinned && p.keyID != key.KeyId { + return nil, time.Time{}, domain.Refuse("hop instance %q registered admission key %s, held until %s: another key (%s) is refused for that instance until then; a new key is registered by a new hop instance", + workload.Instance, p.keyID, p.until.UTC().Format(time.RFC3339), key.KeyId) + } if _, replayed := i.nonces[proved.Nonce]; replayed { return nil, time.Time{}, domain.Refuse("the registration proof was already used: sign a fresh one for each registration") } @@ -87,7 +113,11 @@ func (i *Issuer) RegisterAdmissionKey(ctx context.Context, credential string, pu 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) } + if _, pinned := i.pins[workload.Instance]; !pinned && len(i.pins) >= maxHopInstances { + return nil, time.Time{}, domain.NotNow("%d hop instances hold admission keys; one must lapse before instance %q registers", maxHopInstances, workload.Instance) + } i.nonces[proved.Nonce] = now.Add(nonceRetention) + i.pins[workload.Instance] = pin{keyID: key.KeyId, until: until} i.admission[key.KeyId] = admission{key: key, until: until} return key, until, nil } @@ -105,14 +135,20 @@ func (i *Issuer) admissionKeys() []*authorityv1.PublicKey { return out } -// lapse withdraws every registration past its lease, and forgets every -// registration proof too old to be judged fresh again. Called with mu held. +// lapse withdraws every registration past its lease, releases every hop +// instance's pin past it, and forgets every registration proof too old to be +// judged fresh again. 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) } } + for instance, p := range i.pins { + if !now.Before(p.until) { + delete(i.pins, instance) + } + } for nonce, until := range i.nonces { if !now.Before(until) { delete(i.nonces, nonce) 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 index 29db3c21..5531047a 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/admission_test.go +++ b/modules/authority/services/local/code/pkg/business/issuer/admission_test.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "slices" "strings" "testing" "time" @@ -133,10 +134,12 @@ func TestARotationOverlapsUntilTheRetiringKeyLapses(t *testing.T) { retiring, next := hopSigner(t, "retiring"), hopSigner(t, "next") retiringID, _ := retiring.Admission() nextID, _ := next.Admission() - for _, signer := range []*stamp.Signer{retiring, next} { - if _, _, err := register(t, k, signer, k.HopCredential(t), clock); err != nil { - t.Fatal(err) - } + // The retiring key's hop and the new key's hop are two instances. + if _, _, err := register(t, k, retiring, k.HopInstanceCredential(t, "hop-retiring"), clock); err != nil { + t.Fatal(err) + } + if _, _, err := register(t, k, next, k.HopInstanceCredential(t, "hop-next"), clock); err != nil { + t.Fatal(err) } if ids := published(k); len(ids) != 2 { t.Fatalf("the overlap publishes %v", ids) @@ -144,7 +147,7 @@ func TestARotationOverlapsUntilTheRetiringKeyLapses(t *testing.T) { // Only the new key's hops still run and renew. for range 3 { clock = clock.Add(issuer.AdmissionLease / 2) - if _, _, err := register(t, k, next, k.HopCredential(t), clock); err != nil { + if _, _, err := register(t, k, next, k.HopInstanceCredential(t, "hop-next"), clock); err != nil { t.Fatal(err) } } @@ -162,11 +165,12 @@ func TestARotationOverlapsUntilTheRetiringKeyLapses(t *testing.T) { func TestThePublishedAdmissionKeysAreBounded(t *testing.T) { k := testkit.Bare(t) var last *stamp.Signer + var lastInstance string for n := 0; ; n++ { signer := hopSigner(t, "key-"+string(rune('a'+n))) - _, _, err := register(t, k, signer, k.HopCredential(t), time.Now()) + _, _, err := register(t, k, signer, k.HopInstanceCredential(t, "hop-"+string(rune('a'+n))), time.Now()) if err == nil { - last = signer + last, lastInstance = signer, "hop-"+string(rune('a'+n)) continue } if code, _ := domain.CodeOf(err); code != domain.Precondition || !strings.Contains(err.Error(), "already published") || n < 2 { @@ -174,15 +178,17 @@ func TestThePublishedAdmissionKeysAreBounded(t *testing.T) { } break } - if _, _, err := register(t, k, last, k.HopCredential(t), time.Now()); err != nil { + if _, _, err := register(t, k, last, k.HopInstanceCredential(t, lastInstance), time.Now()); err != nil { t.Fatalf("a registered key no longer renews at the bound: %v", err) } } -// Review #28 F1: the workload credential is a bearer; a registration must also -// prove it holds the key. A captured credential with no proof, with a proof -// by another key, made for another credential or authority, stale, or -// replayed, registers nothing. +// Review #28 F1: a registration carries a proof signed by the key it +// registers, bound to its credential and this authority, fresh and single +// use. No proof, a proof by another key, one made for another credential or +// authority, a stale one or a replayed one, registers nothing. (A proof by a +// key the registrant chose proves only possession of that key; the pin per +// hop instance, below, is what refuses a thief's own key.) func TestARegistrationMustProveItHoldsTheKey(t *testing.T) { k := testkit.Bare(t) clock := time.Now() @@ -257,3 +263,72 @@ func TestOnlyTheHopOfThisHostsTrustDomainRegisters(t *testing.T) { t.Fatalf("this host's hop was refused: %v", err) } } + +// Review #32 B1: the proof alone does not stop a thief with the hop's +// credential, who signs with a key of its own. One key is pinned per hop +// instance: once the hop has registered, a different key under that +// instance's credential is refused by name, even with a valid fresh proof; +// the hop's own key keeps renewing; a new instance (a restarted or rotated +// hop) registers its key; and once an instance's lease lapses its pin is +// released. +func TestAHopInstanceHoldsOneAdmissionKey(t *testing.T) { + k := testkit.Bare(t) + clock := time.Now() + issuer.SetClock(k.Issuer, func() time.Time { return clock }) + hop, thief := hopSigner(t, "the-hop"), hopSigner(t, "thief") + hopID, _ := hop.Admission() + thiefID, _ := thief.Admission() + if _, _, err := register(t, k, hop, k.HopCredential(t), clock); err != nil { + t.Fatal(err) + } + // The review's probe: the stolen credential, the thief's own key, a valid proof. + stolen := k.HopCredential(t) + _, _, err := register(t, k, thief, stolen, clock) + if code, _ := domain.CodeOf(err); code != domain.Denied || !strings.Contains(err.Error(), `hop instance "hop-1" registered admission key `+hopID) || !strings.Contains(err.Error(), thiefID) { + t.Fatalf("a thief's own key registered under the hop's credential: %v", err) + } + if ids := published(k); len(ids) != 1 || ids[0] != hopID { + t.Fatalf("Keys publishes %v, want only the hop's %s", ids, hopID) + } + // The hop renews, under a re-issued credential of the same instance. + clock = clock.Add(issuer.AdmissionLease / 2) + if _, _, err := register(t, k, hop, k.HopCredential(t), clock); err != nil { + t.Fatalf("the hop's renewal was refused: %v", err) + } + if _, _, err := register(t, k, thief, stolen, clock); err == nil { + t.Fatal("a thief's key registered after the hop renewed") + } + // A restarted hop is a new instance: its key, the same or a new one, registers. + restarted := hopSigner(t, "the-hop-after-restart") + restartedID, _ := restarted.Admission() + if _, _, err := register(t, k, restarted, k.HopInstanceCredential(t, "hop-2"), clock); err != nil { + t.Fatalf("a new hop instance was refused: %v", err) + } + if _, _, err := register(t, k, hop, k.HopInstanceCredential(t, "hop-3"), clock); err != nil { + t.Fatalf("a replica sharing the hop's key was refused: %v", err) + } + if ids := published(k); len(ids) != 2 || !slices.Contains(ids, hopID) || !slices.Contains(ids, restartedID) { + t.Fatalf("Keys publishes %v", ids) + } + // Once hop-1 stops renewing and its lease lapses, its pin is released. + clock = clock.Add(issuer.AdmissionLease) + if _, _, err := register(t, k, thief, k.HopCredential(t), clock); err != nil { + t.Fatalf("hop-1's pin outlived its lease: %v", err) + } +} + +// What the pin does not stop, said by a test so it is not mistaken for more: +// a captured credential that registers before its instance's own first +// registration pins the thief's key, and the hop's own registration is then +// refused by name, so the hop's log shows it. +func TestTheFirstRegistrationOfAnInstanceWins(t *testing.T) { + k := testkit.Bare(t) + hop, thief := hopSigner(t, "the-hop"), hopSigner(t, "thief") + thiefID, _ := thief.Admission() + if _, _, err := register(t, k, thief, k.HopCredential(t), time.Now()); err != nil { + t.Fatal(err) + } + if _, _, err := register(t, k, hop, k.HopCredential(t), time.Now()); err == nil || !strings.Contains(err.Error(), "registered admission key "+thiefID) { + t.Fatalf("the hop's registration after a first one by another key: %v", err) + } +} 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 09cc62d2..8a359076 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/issuer.go +++ b/modules/authority/services/local/code/pkg/business/issuer/issuer.go @@ -77,6 +77,9 @@ type Issuer struct { // nonces are the registration proofs accepted and still fresh, each until // it is forgotten, so none is accepted twice. nonces map[string]time.Time + // pins are the admission key each hop instance registered, by instance, + // until that instance's lease lapses. + pins map[string]pin // trustDomain is the host's: only a hop attested in it registers a key. trustDomain string } @@ -85,7 +88,7 @@ type Issuer struct { // 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, now: time.Now, - admission: map[string]admission{}, nonces: map[string]time.Time{}, trustDomain: st.TrustDomain()} + admission: map[string]admission{}, nonces: map[string]time.Time{}, pins: map[string]pin{}, trustDomain: st.TrustDomain()} } // Config is the core configuration for one signing key: what state.New 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 4a17f4f0..20e0e637 100644 --- a/modules/authority/services/local/code/pkg/business/testkit/testkit.go +++ b/modules/authority/services/local/code/pkg/business/testkit/testkit.go @@ -65,7 +65,17 @@ func HopWorkload() workloadidentity.Workload { // 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) + return k.HopInstanceCredential(t, HopWorkload().Instance) +} + +// HopInstanceCredential is what the launcher signed for one hop process, +// named by its instance: another instance is another hop process (a +// restart under a new instance, or a rotation's new hop). +func (k *Kit) HopInstanceCredential(t *testing.T, instance string) string { + t.Helper() + workload := HopWorkload() + workload.Instance = instance + token, err := k.Launcher.Issue(native.Subject{Workload: workload, Container: issuer.HopService}, Audience) if err != nil { t.Fatal(err) } diff --git a/modules/authority/services/local/code/plugins/authority/plugin.go b/modules/authority/services/local/code/plugins/authority/plugin.go index 476761ad..db76c317 100644 --- a/modules/authority/services/local/code/plugins/authority/plugin.go +++ b/modules/authority/services/local/code/plugins/authority/plugin.go @@ -222,8 +222,8 @@ func (p *Plugin) Keys(context.Context, *authorityv1.KeysRequest) (*authorityv1.K } // RegisterAdmissionKey publishes the hop's admission key in Keys, for the hop -// alone and only with its proof of holding the key, until its registration -// lapses. +// alone, one key per hop instance, until its registration lapses +// (issuer.RegisterAdmissionKey says what that guarantees and what it does not). 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(), req.GetProof()) if err != nil { 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 index 088031dc..c7940633 100644 --- 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 @@ -148,6 +148,12 @@ func TestUnderThePlaygroundsIdentityTheHopsRegisteredKeyAdmitsItsStamps(t *testi if err != nil { t.Fatal(err) } + // Review #32 B1: and so is the captured credential with the thief's own + // key and a valid proof by it: the hop's instance holds the hop's key. + if _, err := register(string(credential), impostor); status.Code(err) != codes.PermissionDenied || + !strings.Contains(err.Error(), "registered admission key "+id) { + t.Fatalf("a captured hop credential registered the thief's own key: %v", err) + } for name, proof := range map[string]string{"no proof": "", "the hop's proof": hopProof} { if _, err := client.RegisterAdmissionKey(ctx, &authorityv1.RegisterAdmissionKeyRequest{ Actor: &authorityv1.WorkloadCredential{Credential: string(credential)}, PublicKey: impostorKey, Proof: proof}); status.Code(err) != codes.PermissionDenied ||