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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,32 @@ same registration is follow-up work in the host. The hop calls it, so the hop
requires `>=0.3.3 <0.4.0`: a host below 0.3.3 is refused at render instead of
leaving every module's verifier to refuse every call.

`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. 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
`AuthorizeTransfer` and adds attachment operations. Composition remains 0.2.0.
Expand Down
2 changes: 1 addition & 1 deletion interfaces/authority/interface.codefly.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion libraries/authority-client/contract/catalog.codefly.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"kind": "protobuf",
"package": "api",
"path": "contract.binpb",
"digest": "sha256:54359a530ee98bd87f610c7f37efc6a32010357635f6dabb8ceabae78d8a9ec5",
"digest": "sha256:77fabcf3c462196511203130231d7aca3a21140a4e3d1eab5ceaabbbde08d61e",
"services": [
{
"name": "LocalService",
Expand Down
Binary file modified libraries/authority-client/contract/contract.binpb
Binary file not shown.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion libraries/authority-client/go/stamp/call_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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[:])) },
Expand Down Expand Up @@ -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:]
}
118 changes: 118 additions & 0 deletions libraries/authority-client/go/stamp/registration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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 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")
}
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 <payload>.<signature>: 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] {
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
}
Loading
Loading