From eaf17b93f0a4a859516ed827150a7e696a0b3704 Mon Sep 17 00:00:00 2001 From: btoews Date: Thu, 12 Oct 2023 10:26:15 -0600 Subject: [PATCH 01/15] s/cid/ticket/ --- README.md | 52 ++++++++++++++--------------- caveat_set_test.go | 2 +- caveats.go | 6 ++-- cid.go | 38 +++++++++++----------- go.mod | 1 + go.sum | 5 +++ macaroon.go | 81 +++++++++++++++++++++++----------------------- macaroon_test.go | 38 +++++++++++----------- 8 files changed, 114 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 59a0d25..20db686 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ An ordinary caveat is checked by looking at the request and the caveat and seein A "third party \(3P\)" caveat works differently. 3P caveats demand that some other named system validate the request. -Users extract a little ticket from the 3P caveat \(that ticket is called a "CID"\) and hands it to the third party, along with anything else the third party might want. That third party resolves the caveat by generating a "discharge Macaroon", which is a whole 'nother token, tied cryptographically to the original 3P caveat. The user then presents both the original Macaroon and the discharge Macaroon with their request. +Users extract a little ticket from the 3P caveat and hands it to the third party, along with anything else the third party might want. That third party resolves the caveat by generating a "discharge Macaroon", which is a whole 'nother token, tied cryptographically to the original 3P caveat. The user then presents both the original Macaroon and the discharge Macaroon with their request. For instance: most Fly.io Macaroons require a logged\-in user \(usually a member of a particular organization\). We express that with a 3P caveat pointing to our authentication endpoint. That endpoint checks to see who you're logged in as, and produces an appropriate discharge, which accompanies the original Macaroon and \(in effect\) attests to you being logged in. @@ -68,7 +68,7 @@ See the \`flyio\` package for more details. - [Constants](<#constants>) - [Variables](<#variables>) -- [func DischargeCID\(ka EncryptionKey, location string, cid \[\]byte\) \(\[\]Caveat, \*Macaroon, error\)](<#DischargeCID>) +- [func DischargeTicket\(ka EncryptionKey, location string, ticket \[\]byte\) \(\[\]Caveat, \*Macaroon, error\)](<#DischargeTicket>) - [func FindPermissionAndDischargeTokens\(tokens \[\]\[\]byte, location string\) \(\[\]\*Macaroon, \[\]\[\]byte, \[\]\*Macaroon, \[\]\[\]byte, error\)](<#FindPermissionAndDischargeTokens>) - [func GetCaveats\[T Caveat\]\(c \*CaveatSet\) \(ret \[\]T\)](<#GetCaveats>) - [func IsAttestation\(c Caveat\) bool](<#IsAttestation>) @@ -76,7 +76,7 @@ See the \`flyio\` package for more details. - [func ParsePermissionAndDischargeTokens\(header string, location string\) \(\[\]byte, \[\]\[\]byte, error\)](<#ParsePermissionAndDischargeTokens>) - [func RegisterCaveatJSONAlias\(typ CaveatType, alias string\)](<#RegisterCaveatJSONAlias>) - [func RegisterCaveatType\(zeroValue Caveat\)](<#RegisterCaveatType>) -- [func ThirdPartyCID\(encodedMacaroon \[\]byte, thirdPartyLocation string\) \(\[\]byte, error\)](<#ThirdPartyCID>) +- [func ThirdPartyTicket\(encodedMacaroon \[\]byte, thirdPartyLocation string\) \(\[\]byte, error\)](<#ThirdPartyTicket>) - [func ToAuthorizationHeader\(toks ...\[\]byte\) string](<#ToAuthorizationHeader>) - [func Validate\[A Access\]\(cs \*CaveatSet, accesses ...A\) error](<#Validate>) - [type Access](<#Access>) @@ -111,8 +111,8 @@ See the \`flyio\` package for more details. - [func \(m \*Macaroon\) BindToParentMacaroon\(parent \*Macaroon\) error](<#Macaroon.BindToParentMacaroon>) - [func \(m \*Macaroon\) Encode\(\) \(\[\]byte, error\)](<#Macaroon.Encode>) - [func \(m \*Macaroon\) Expiration\(\) time.Time](<#Macaroon.Expiration>) - - [func \(m \*Macaroon\) ThirdPartyCID\(location string, existingDischarges ...\[\]byte\) \(\[\]byte, error\)](<#Macaroon.ThirdPartyCID>) - - [func \(m \*Macaroon\) ThirdPartyCIDs\(existingDischarges ...\[\]byte\) \(map\[string\]\[\]byte, error\)](<#Macaroon.ThirdPartyCIDs>) + - [func \(m \*Macaroon\) ThirdPartyTicket\(location string, existingDischarges ...\[\]byte\) \(\[\]byte, error\)](<#Macaroon.ThirdPartyTicket>) + - [func \(m \*Macaroon\) ThirdPartyTickets\(existingDischarges ...\[\]byte\) \(map\[string\]\[\]byte, error\)](<#Macaroon.ThirdPartyTickets>) - [func \(m \*Macaroon\) Verify\(k SigningKey, discharges \[\]\[\]byte, trusted3Ps map\[string\]EncryptionKey\) \(\*CaveatSet, error\)](<#Macaroon.Verify>) - [type Nonce](<#Nonce>) - [func DecodeNonce\(buf \[\]byte\) \(Nonce, error\)](<#DecodeNonce>) @@ -152,14 +152,14 @@ var ( ) ``` - -## func DischargeCID + +## func DischargeTicket ```go -func DischargeCID(ka EncryptionKey, location string, cid []byte) ([]Caveat, *Macaroon, error) +func DischargeTicket(ka EncryptionKey, location string, ticket []byte) ([]Caveat, *Macaroon, error) ``` -Decyrpts the CID from the 3p caveat and prepares a discharge token. Returned caveats, if any, must be validated before issuing the discharge token to the user. +Decyrpts the ticket from the 3p caveat and prepares a discharge token. Returned caveats, if any, must be validated before issuing the discharge token to the user. ## func FindPermissionAndDischargeTokens @@ -224,14 +224,14 @@ func RegisterCaveatType(zeroValue Caveat) Register a caveat type for use with this library. - -## func ThirdPartyCID + +## func ThirdPartyTicket ```go -func ThirdPartyCID(encodedMacaroon []byte, thirdPartyLocation string) ([]byte, error) +func ThirdPartyTicket(encodedMacaroon []byte, thirdPartyLocation string) ([]byte, error) ``` -Checks the macaroon for a third party caveat for the specified location. Returns the caveat's encrypted CID, if found. +Checks the macaroon for a third party caveat for the specified location. Returns the caveat's encrypted ticket, if found. ## func ToAuthorizationHeader @@ -347,9 +347,9 @@ Caveat3P is a requirement that the token be presented along with a 3P discharge ```go type Caveat3P struct { - Location string - VID []byte // used by the initial issuer to verify discharge macaroon - CID []byte // used by the 3p service to construct discharge macaroon + Location string + VerifierKey []byte // used by the initial issuer to verify discharge macaroon + Ticket []byte // used by the 3p service to construct discharge macaroon // contains filtered or unexported fields } ``` @@ -631,27 +631,27 @@ func (m *Macaroon) Expiration() time.Time Expiration calculates when this macaroon will expire - -### func \(\*Macaroon\) ThirdPartyCID + +### func \(\*Macaroon\) ThirdPartyTicket ```go -func (m *Macaroon) ThirdPartyCID(location string, existingDischarges ...[]byte) ([]byte, error) +func (m *Macaroon) ThirdPartyTicket(location string, existingDischarges ...[]byte) ([]byte, error) ``` -ThirdPartyCID returns the CID \(see \[Macaron.ThirdPartyCIDs\]\) associated with a URL location, if possible. +ThirdPartyTicket returns the ticket \(see \[Macaron.ThirdPartyTickets\]\) associated with a URL location, if possible. - -### func \(\*Macaroon\) ThirdPartyCIDs + +### func \(\*Macaroon\) ThirdPartyTickets ```go -func (m *Macaroon) ThirdPartyCIDs(existingDischarges ...[]byte) (map[string][]byte, error) +func (m *Macaroon) ThirdPartyTickets(existingDischarges ...[]byte) (map[string][]byte, error) ``` -ThirdPartyCIDs extracts the encrypted CIDs from a token's third party caveats. +ThirdPartyTickets extracts the encrypted tickets from a token's third party caveats. -The CID of a third\-party caveat is a little ticket embedded in the caveat that is readable by the third\-party service for which it's intended. That service uses the CID to generate a compatible discharge token to satisfy the caveat. +The ticket of a third\-party caveat is a little ticket embedded in the caveat that is readable by the third\-party service for which it's intended. That service uses the ticket to generate a compatible discharge token to satisfy the caveat. -Macaroon services of all types are identified by their "location", which in our scheme is always a URL. ThirdPartyCIDs returns a map of location to CID. In a perfect world, you could iterate over this map hitting each URL and passing it the associated CID, collecting all the discharge tokens you need for the request \(it is never that simple, though\). +Macaroon services of all types are identified by their "location", which in our scheme is always a URL. ThirdPartyTickets returns a map of location to ticket. In a perfect world, you could iterate over this map hitting each URL and passing it the associated ticket, collecting all the discharge tokens you need for the request \(it is never that simple, though\). Already\-discharged caveats are excluded from the results. diff --git a/caveat_set_test.go b/caveat_set_test.go index 4d74fa0..ff9221e 100644 --- a/caveat_set_test.go +++ b/caveat_set_test.go @@ -25,7 +25,7 @@ func TestJSON(t *testing.T) { func TestCaveatSerialization(t *testing.T) { cs := NewCaveatSet( &ValidityWindow{NotBefore: 123, NotAfter: 234}, - &Caveat3P{Location: "123", VID: []byte("123"), CID: []byte("123")}, + &Caveat3P{Location: "123", VerifierKey: []byte("123"), Ticket: []byte("123")}, &BindToParentToken{1, 2, 3}, ) diff --git a/caveats.go b/caveats.go index c19a32c..0ef9e6e 100644 --- a/caveats.go +++ b/caveats.go @@ -7,9 +7,9 @@ import ( // Caveat3P is a requirement that the token be presented along with a 3P discharge token. type Caveat3P struct { - Location string - VID []byte // used by the initial issuer to verify discharge macaroon - CID []byte // used by the 3p service to construct discharge macaroon + Location string + VerifierKey []byte // used by the initial issuer to verify discharge macaroon + Ticket []byte // used by the 3p service to construct discharge macaroon // HMAC key for 3P caveat rn []byte `msgpack:"-"` diff --git a/cid.go b/cid.go index f24319e..9381093 100644 --- a/cid.go +++ b/cid.go @@ -6,52 +6,52 @@ import ( msgpack "github.com/vmihailenco/msgpack/v5" ) -// wireCID is the magic blob callers pass to 3rd-party services to obtain discharge +// wireTicket is the magic blob callers pass to 3rd-party services to obtain discharge // Macaroons for 3P claims (the Macaroon Caveat itself has a Location field that // tells callers where to send these). // // This is the plaintext of a blob that is encrypted in the actual Macaroon. // -// Just remember: users exchange CIDs for discharge Macaroons -type wireCID struct { - RN []byte - Caveats CaveatSet +// Just remember: users exchange tickets for discharge Macaroons +type wireTicket struct { + DischargeKey []byte + Caveats CaveatSet } // Checks the macaroon for a third party caveat for the specified location. -// Returns the caveat's encrypted CID, if found. -func ThirdPartyCID(encodedMacaroon []byte, thirdPartyLocation string) ([]byte, error) { +// Returns the caveat's encrypted ticket, if found. +func ThirdPartyTicket(encodedMacaroon []byte, thirdPartyLocation string) ([]byte, error) { m, err := Decode(encodedMacaroon) if err != nil { return nil, err } - return m.ThirdPartyCID(thirdPartyLocation) + return m.ThirdPartyTicket(thirdPartyLocation) } -// Decyrpts the CID from the 3p caveat and prepares a discharge token. Returned +// Decyrpts the ticket from the 3p caveat and prepares a discharge token. Returned // caveats, if any, must be validated before issuing the discharge token to the // user. -func DischargeCID(ka EncryptionKey, location string, cid []byte) ([]Caveat, *Macaroon, error) { - return dischargeCID(ka, location, cid, true) +func DischargeTicket(ka EncryptionKey, location string, ticket []byte) ([]Caveat, *Macaroon, error) { + return dischargeTicket(ka, location, ticket, true) } // discharge macaroons will be proofs moving forward, but we need to be able to test the old non-proof dms too -func dischargeCID(ka EncryptionKey, location string, cid []byte, issueProof bool) ([]Caveat, *Macaroon, error) { - cidr, err := unseal(ka, cid) +func dischargeTicket(ka EncryptionKey, location string, ticket []byte, issueProof bool) ([]Caveat, *Macaroon, error) { + ticketr, err := unseal(ka, ticket) if err != nil { - return nil, nil, fmt.Errorf("recover for discharge: CID decrypt: %w", err) + return nil, nil, fmt.Errorf("recover for discharge: ticket decrypt: %w", err) } - tcid := &wireCID{} - if err = msgpack.Unmarshal(cidr, tcid); err != nil { - return nil, nil, fmt.Errorf("recover for discharge: CID decode: %w", err) + tticket := &wireTicket{} + if err = msgpack.Unmarshal(ticketr, tticket); err != nil { + return nil, nil, fmt.Errorf("recover for discharge: ticket decode: %w", err) } - dm, err := newMacaroon(cid, location, tcid.RN, issueProof) + dm, err := newMacaroon(ticket, location, tticket.DischargeKey, issueProof) if err != nil { return nil, nil, err } - return tcid.Caveats.Caveats, dm, nil + return tticket.Caveats.Caveats, dm, nil } diff --git a/go.mod b/go.mod index c344cdd..16c35e6 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.20 require ( github.com/alecthomas/assert/v2 v2.3.0 github.com/google/uuid v1.3.0 + github.com/sirupsen/logrus v1.9.3 github.com/vmihailenco/msgpack/v5 v5.3.5 golang.org/x/crypto v0.12.0 golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 diff --git a/go.sum b/go.sum index bcf3f7f..16db810 100644 --- a/go.sum +++ b/go.sum @@ -4,14 +4,18 @@ github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= @@ -22,6 +26,7 @@ golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/macaroon.go b/macaroon.go index 13adb95..6d757c7 100644 --- a/macaroon.go +++ b/macaroon.go @@ -24,10 +24,9 @@ // A "third party (3P)" caveat works differently. 3P caveats demand // that some other named system validate the request. // -// Users extract a little ticket from the 3P caveat (that ticket -// is called a "CID") and hands it to the third party, along with -// anything else the third party might want. That third party resolves -// the caveat by generating a "discharge Macaroon", which is a whole +// Users extract a little ticket from the 3P caveat and hands it to the third +// party, along with anything else the third party might want. That third party +// resolves the caveat by generating a "discharge Macaroon", which is a whole // 'nother token, tied cryptographically to the original 3P // caveat. The user then presents both the original Macaroon and the // discharge Macaroon with their request. @@ -219,7 +218,7 @@ func (m *Macaroon) Add(caveats ...Caveat) error { if c3p, ok := caveat.(*Caveat3P); ok { // encrypt RN under the tail hmac so we can recover it during verification - c3p.VID = seal(EncryptionKey(m.Tail), c3p.rn) + c3p.VerifierKey = seal(EncryptionKey(m.Tail), c3p.rn) if seen3P[c3p.Location] { return fmt.Errorf("m.add: attempting to add multiple 3ps for %s", c3p.Location) @@ -312,14 +311,14 @@ func (m *Macaroon) verify(k SigningKey, discharges [][]byte, parentTokenBindingI trusted3Ps = map[string]EncryptionKey{} } - dischargeByCID := make(map[string]*Macaroon, len(discharges)) + dischargeByTicket := make(map[string]*Macaroon, len(discharges)) for _, dBuf := range discharges { decoded, err := Decode(dBuf) if err != nil { continue // ignore malformed discharges } - dischargeByCID[string(decoded.Nonce.KID)] = decoded + dischargeByTicket[string(decoded.Nonce.KID)] = decoded } curMac := sign(k, m.Nonce.MustEncode()) @@ -331,20 +330,20 @@ func (m *Macaroon) verify(k SigningKey, discharges [][]byte, parentTokenBindingI k SigningKey } - dischargesToVerify := make([]*verifyParams, 0, len(dischargeByCID)) + dischargesToVerify := make([]*verifyParams, 0, len(dischargeByTicket)) thisTokenBindingIds := [][]byte{digest(curMac)} for _, c := range m.UnsafeCaveats.Caveats { switch cav := c.(type) { case *Caveat3P: - discharge, ok := dischargeByCID[string(cav.CID)] + discharge, ok := dischargeByTicket[string(cav.Ticket)] if !ok { return nil, errors.New("no matching discharge token") } - dischargeKey, err := unseal(EncryptionKey(curMac), cav.VID) + dischargeKey, err := unseal(EncryptionKey(curMac), cav.VerifierKey) if err != nil { - return nil, fmt.Errorf("macaroon verify: unseal VID for third-party caveat: %w", err) + return nil, fmt.Errorf("macaroon verify: unseal VerifierKey for third-party caveat: %w", err) } dischargesToVerify = append(dischargesToVerify, &verifyParams{discharge, dischargeKey}) @@ -382,21 +381,21 @@ func (m *Macaroon) verify(k SigningKey, discharges [][]byte, parentTokenBindingI for _, d := range dischargesToVerify { // If the discharge was actually created by a known third party we can // trust its attestations. Verify this by comparing signing key from - // VID/CID. + // VerifierKey/ticket. var trustedDischarge bool if ka, ok := trusted3Ps[d.m.Location]; ok { - cidr, err := unseal(ka, d.m.Nonce.KID) + ticketr, err := unseal(ka, d.m.Nonce.KID) if err != nil { - return ret, fmt.Errorf("discharge cid decrypt: %w", err) + return ret, fmt.Errorf("discharge ticket decrypt: %w", err) } - var cid wireCID - if err = msgpack.Unmarshal(cidr, &cid); err != nil { - return ret, fmt.Errorf("bad cid in discharge: %w", err) + var ticket wireTicket + if err = msgpack.Unmarshal(ticketr, &ticket); err != nil { + return ret, fmt.Errorf("bad ticket in discharge: %w", err) } - if subtle.ConstantTimeCompare(d.k, cid.RN) != 1 { - return ret, errors.New("discharge key from CID/VID mismatch") + if subtle.ConstantTimeCompare(d.k, ticket.DischargeKey) != 1 { + return ret, errors.New("discharge key from ticket/VerifierKey mismatch") } trustedDischarge = true @@ -490,50 +489,50 @@ func (m *Macaroon) Add3P(ka EncryptionKey, loc string, cs ...Caveat) error { // make a new root hmac key for the 3p discharge macaroon rn := NewSigningKey() - // make the CID, which is consumed by the 3p service; then + // make the ticket, which is consumed by the 3p service; then // encode and encrypt it - cid := &wireCID{ - RN: rn, - Caveats: *NewCaveatSet(cs...), + ticket := &wireTicket{ + DischargeKey: rn, + Caveats: *NewCaveatSet(cs...), } - cidBytes, err := encode(cid) + ticketBytes, err := encode(ticket) if err != nil { - return fmt.Errorf("encoding CID: %w", err) + return fmt.Errorf("encoding ticket: %w", err) } m.Add(&Caveat3P{ Location: loc, - CID: seal(ka, cidBytes), + Ticket: seal(ka, ticketBytes), rn: rn, }) return nil } -// ThirdPartyCIDs extracts the encrypted CIDs from a token's third party +// ThirdPartyTickets extracts the encrypted tickets from a token's third party // caveats. // -// The CID of a third-party caveat is a little ticket embedded in the +// The ticket of a third-party caveat is a little ticket embedded in the // caveat that is readable by the third-party service for which it's -// intended. That service uses the CID to generate a compatible discharge +// intended. That service uses the ticket to generate a compatible discharge // token to satisfy the caveat. // // Macaroon services of all types are identified by their "location", -// which in our scheme is always a URL. ThirdPartyCIDs returns a map -// of location to CID. In a perfect world, you could iterate over this -// map hitting each URL and passing it the associated CID, collecting +// which in our scheme is always a URL. ThirdPartyTickets returns a map +// of location to ticket. In a perfect world, you could iterate over this +// map hitting each URL and passing it the associated ticket, collecting // all the discharge tokens you need for the request (it is never that // simple, though). // // Already-discharged caveats are excluded from the results. -func (m *Macaroon) ThirdPartyCIDs(existingDischarges ...[]byte) (map[string][]byte, error) { +func (m *Macaroon) ThirdPartyTickets(existingDischarges ...[]byte) (map[string][]byte, error) { ret := map[string][]byte{} - dischargeCIDs := make(map[string]struct{}, len(existingDischarges)) + dischargeTickets := make(map[string]struct{}, len(existingDischarges)) for _, ed := range existingDischarges { if n, err := DecodeNonce(ed); err == nil { - dischargeCIDs[hex.EncodeToString(n.KID)] = struct{}{} + dischargeTickets[hex.EncodeToString(n.KID)] = struct{}{} } } @@ -542,23 +541,23 @@ func (m *Macaroon) ThirdPartyCIDs(existingDischarges ...[]byte) (map[string][]by return nil, fmt.Errorf("extract third party caveats: duplicate locations: %s", cav.Location) } - if _, discharged := dischargeCIDs[hex.EncodeToString(m.Nonce.KID)]; !discharged { - ret[cav.Location] = cav.CID + if _, discharged := dischargeTickets[hex.EncodeToString(m.Nonce.KID)]; !discharged { + ret[cav.Location] = cav.Ticket } } return ret, nil } -// ThirdPartyCID returns the CID (see [Macaron.ThirdPartyCIDs]) associated +// ThirdPartyTicket returns the ticket (see [Macaron.ThirdPartyTickets]) associated // with a URL location, if possible. -func (m *Macaroon) ThirdPartyCID(location string, existingDischarges ...[]byte) ([]byte, error) { - cids, err := m.ThirdPartyCIDs(existingDischarges...) +func (m *Macaroon) ThirdPartyTicket(location string, existingDischarges ...[]byte) ([]byte, error) { + tickets, err := m.ThirdPartyTickets(existingDischarges...) if err != nil { return nil, err } - return cids[location], nil + return tickets[location], nil } // https://stackoverflow.com/questions/25065055/what-is-the-maximum-time-time-in-go diff --git a/macaroon_test.go b/macaroon_test.go index 29aeb09..1e0dd16 100644 --- a/macaroon_test.go +++ b/macaroon_test.go @@ -339,24 +339,24 @@ func TestMacaroons(t *testing.T) { unboundDischarge := discharges[0] - cids, err := decoded.ThirdPartyCIDs() + tickets, err := decoded.ThirdPartyTickets() assert.NoError(t, err) - cid := cids["other loc"] + ticket := tickets["other loc"] - rcid, err := unseal(tpKey, cid) + rticket, err := unseal(tpKey, ticket) assert.NoError(t, err) - wcid := &wireCID{} - assert.NoError(t, msgpack.Unmarshal(rcid, wcid)) + wticket := &wireTicket{} + assert.NoError(t, msgpack.Unmarshal(rticket, wticket)) dum, err := Decode(unboundDischarge) assert.NoError(t, err) - _, err = dum.verify(wcid.RN, nil, nil, true, nil) + _, err = dum.verify(wticket.DischargeKey, nil, nil, true, nil) assert.NoError(t, err) - _, err = dum.verify(wcid.RN, nil, [][]byte{{123}}, true, nil) + _, err = dum.verify(wticket.DischargeKey, nil, [][]byte{{123}}, true, nil) assert.NoError(t, err) }) @@ -433,11 +433,11 @@ func Test3pe2e(t *testing.T) { rm, err := Decode(rBuf) assert.NoError(t, err) - tps, err := rm.ThirdPartyCIDs() + tps, err := rm.ThirdPartyTickets() assert.NoError(t, err) - cid := tps[authLoc] - _, dm, err := dischargeCID(ka, authLoc, cid, isProof) + ticket := tps[authLoc] + _, dm, err := dischargeTicket(ka, authLoc, ticket, isProof) assert.NoError(t, err) assert.NoError(t, dm.Add(cavExpiry(5*time.Minute))) @@ -447,10 +447,10 @@ func Test3pe2e(t *testing.T) { verifiedCavs, err := rm.Verify(key, [][]byte{aBuf}, nil) assert.NoError(t, err) - _, _, err = dischargeCID(ka, authLoc, cid, isProof) + _, _, err = dischargeTicket(ka, authLoc, ticket, isProof) assert.NoError(t, err) - cid[10] = 0 - _, _, err = dischargeCID(ka, authLoc, cid, isProof) + ticket[10] = 0 + _, _, err = dischargeTicket(ka, authLoc, ticket, isProof) assert.Error(t, err) err = verifiedCavs.Validate(&testAccess{ @@ -515,11 +515,11 @@ func TestSimple3P(t *testing.T) { decoded, err := Decode(rBuf) assert.NoError(t, err) - tps, err := decoded.ThirdPartyCIDs() + tps, err := decoded.ThirdPartyTickets() assert.NoError(t, err) - cid := tps[authLoc] + ticket := tps[authLoc] - _, dm, err := dischargeCID(ka, authLoc, cid, isProof) + _, dm, err := dischargeTicket(ka, authLoc, ticket, isProof) assert.NoError(t, err) assert.NoError(t, dm.Add(cavExpiry(5*time.Minute))) aBuf, err := dm.Encode() @@ -632,11 +632,11 @@ func TestDuplicateCaveats(t *testing.T) { } func dischargeMacaroon(ka EncryptionKey, location string, encodedMacaroon []byte) (bool, []Caveat, *Macaroon, error) { - cid, err := ThirdPartyCID(encodedMacaroon, location) - if err != nil || len(cid) == 0 { + ticket, err := ThirdPartyTicket(encodedMacaroon, location) + if err != nil || len(ticket) == 0 { return false, nil, nil, err } - dcavs, dm, err := DischargeCID(ka, location, cid) + dcavs, dm, err := DischargeTicket(ka, location, ticket) return true, dcavs, dm, err } From 6335f4d055f0aea3572c1ee6f5bd5c64677d5519 Mon Sep 17 00:00:00 2001 From: btoews Date: Thu, 12 Oct 2023 10:26:35 -0600 Subject: [PATCH 02/15] specification for 3p discharge protocol --- tp/README.md | 211 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 tp/README.md diff --git a/tp/README.md b/tp/README.md new file mode 100644 index 0000000..4871e51 --- /dev/null +++ b/tp/README.md @@ -0,0 +1,211 @@ +# Third Party Discharge Protocol + +Clients wishing to communicate with macaroon-authenticated services (1st parties or 1ps) require a mechanism for communicating with third parties (3ps) in order to obtain discharge macaroons that satisfy any third party caveats in the 1st party macaroon. This document defines a protocol allowing clients to request discharge macaroons from 3rd parties. + +If this reads like gibberish so far, check out the [background](#background) section. + +There are (at least) three possible intents behind a 3p caveat: + +1. The need to authorize the principal. E.g. to authenticate a user. +1. The need to authorize the client. E.g. to enforce a rate-limiting requirements on clients. +1. The need to authorize some ambient state. E.g. to only allow tokens to be used on Tuesdays. + +The client may be the principal themselves (e.g. a user using cURL or other CLI tooling) or may be another entity acting on the principal's behalf (e.g. a service using the 1p API for the user). + +## Protocol Flows + +Clients initiate their request for a discharge macaroon by making an HTTP POST request directly to the 3p service: + +```http +POST /.well-known/macfly/3p +Host: <3p-location> +Content-Type: application/json +Cookie: +Authorization: + +{ + "ticket": "" +} +``` + +The request body is a JSON encoded object with the base64 encoded ticket to be discharged specified in the `ticket` field. If the 3p's location identifier includes a URL path, it will be included before the `/.well-known` path segment. + +The client MAY authenticate itself using the `Authorization` header if the 3p and client have established a mechanism for client authentication. The client MAY maintain a per-principal cookie jar allowing for future discharge flows to be expedited. + +The 3p will respond with status 201 on success: + +```http +HTTP/1.1 201 Created +Content-Type: application/json +Set-Cookie: + +{ + "discharge": "base64 encoded discharge macaroon", + "poll": "/url/or/path/to/poll", +} +``` + +If an error is encountered, an appropriate HTTP status code will be returned and the response body will contain a JSON object with an `error` field: + +```http +HTTP/1.1 420 Enhance your calm +Content-Type: application/json + +{ + "error": "too many requests. try again later" +} +``` + +The contents of the JSON object returned in successful responses will indicate how the client may proceed with obtaining a discharge for the requested ticket. This will vary based on the capabilities of the client and 3p and on the presence of client authentication or principal cookies. 3ps will only need to implement a subset of the following response types, but clients should be prepared to handle any of them. + +### Immediate Response + +If no client<->3p interaction beyond the initial request is required, the server may respond with an `discharge` field in the response body: + +```http +HTTP/1.1 201 Created +Content-Type: application/json + +{ + "discharge": "base64 encoded discharge macaroon" +} +``` + +This may be the case when the 3p caveat was intended to authorize ambient state or the client itself or when the principal-cookie provided by the client adequately authorized the principal. + +### Poll Response + +If no client<->3p interaction beyond the initial request is required, but the server was not able to respond with a discharge token immediately, the server may respond with a `poll_url` field in the response body: + +```http +HTTP/1.1 201 Created +Content-Type: application/json + +{ + "poll_url": "" +} +``` + +This may be the case if the 3p needs to authorize the request via some out-of-band process, like sending the user a confirmation link via email or SMS. + +The client may continue to request the specified polling endpoint to check if the discharge is ready. If the discharge is not ready yet, the server will respond with an empty response with status code 202. + +```http +HTTP/1.1 202 Accepted +``` + +Once the discharge is ready, the server will return it with a status code 200: + +```http +HTTP/1.1 200 Ok +Content-Type: application/json + +{ + "discharge": "base64 encoded discharge macaroon" +} +``` + +If the flow completed unsuccessfully, the 3p will return a 200 response with a JSON body containing `error` field: + +```http +HTTP/1.1 200 Ok +Content-Type: application/json + +{ + "error": "user rejected approval" +} +``` + +The poll URL MUST include enough entropy to make it unguessable. Clients maintaining a cookie jar for the principal should continue sending `Cookie` header in poll requests and processing `Set-Cookie` headers in responses. Clients capable of authenticating themselves to the 3p should continue sending the `Authorization` header on poll requests. + +Once the flow has completed and the server has returned a single 200 response to a polling request, the server may deregister the polling endpoint and begin returning 404 status codes. If the flow completes and the 3p hasn't received a request to the polling endpoint within a reasonable amount of time, they may also deregister the polling endpoint. + +### User Interactive Response + +The 3p may need to interact directly with the principal by having them performing some flow via a web browser. For example, the 3p might need the user do a WebAuthn exchange or solve a CAPTCHA. In this case, the 3p's response body will include a `user_interactive` field: + +```http +HTTP/1.1 201 Created +Content-Type: application/json + +{ + "user_interactive": { + "user_url": "", + "poll_url": "" + } +} +``` + +To continue with this flow, the client may navigate the user to the specified `user_url` where they will interact with the 3p directly. Web-based clients that are interacting with the user via their web browser can achieve this navigation by redirecting the user. Other clients (e.g. CLI apps) can display the URL and instruct the user to visit it. + +If the client wants the user to be redirected to a specific URL once the their interaction with the 3p is completed, they may include add a `return_to` parameter to the query string when navigating the user to the `user_url`. This may be useful for clients that don't want to poll the `poll_url`, but would rather receive a request to indicate the completion of the flow. + +The client may make requests to the `poll_url` as they would for the [Poll Response](#poll-response) described above. + +## Background + +Third party (3p) caveats require the principal to fetch a discharge macaroon from a third party service before the base macaroon is considered valid. + +For example, Alice wants to give Bob a https://service.com macaroon that only works once he's proven his identity to https://login.com. She adds an https://login.com 3p caveat requiring `user=bob` to her https://service.com macaroon and then gives it to Bob. Bob proves his identity to https://login.com and receives a discharge macaroon. He can how use the two macaroons together to access https://service.com. + +### Cast of characters + +- _First party (1p)_ - The service that requires macaroon authentication. In the opening example, this is https://service.com +- _Third party (3p)_ - A service that is capable of issuing discharge macaroons. In the opening example, this is https://login.com +- _Macaroon attenuator_ - An entity that adds caveats to macaroon(s) in their possession. This could be the 1p during initial macaroon issuance, or the user once they posses a macaroon. In the opening example, this is Alice +- _Principal_ - An entity using macaroon(s) to access the 1p service. In the opening example, this is Bob + +### Third Party Caveat Mechanics + +For each 3p caveat, the macaroon attenuator generates a random key that will be used to issue discharge macaroons. It encrypts this key, along with a set of caveats that must be checked by the 3p, under a symmetric key that is shared with the 3p. This encrypted blob is called the ticket and is stored in the 3p caveat. When the 3p receives a ticket, it is able to decrypt it, clear the included caveats, and issue a discharge macaroon using the random key. The `kid` field of the discharge macaroon's `nonce` is the encrypted ticket. This allows the discharge macaroon to be easily matched with the 3p caveat it was generated for. + +```go +type Ticket struct { + DischargeKey []byte + Caveats CaveatSet +} +``` + +The 1p must also be able to learn the discharge key in order to clear the discharge macaroon. To facilitate this, the random discharge key is also encrypted under the current macaroon tail (signature), using it as a symmetric key. This encrypted blob is called the VerifierKey and is also stored in the 3p caveat. + +```go +type Caveat3P struct { + Location string + VerifierKey []byte + Ticket []byte +} +``` + +The flow, in its entirely, is as follows: + +- Alice: + 1. Having an https://service.com macaroon and a symmetric key shared with https://login.com, + 1. Generates a random discharge key + 1. Encrypts the discharge key and a `user=bob` caveat under the key shared with https://login.com + 1. Encrypts the discharge key under the current macaroon tail + 1. Adds a `Caveat3P` to her https://service.com macaroon with `location=https://login.com`, the ticket, and the VerifierKey. + 1. Gives the updated https://service.com macaroon to Bob +- Bob: + 1. Having the updated https://service.com macaroon, + 1. Searches the macaroon for https://login.com caveats that it doesn't already posses discharge tokens for + 1. Extracts the encrypted ticket from the 3P caveat + 1. Makes a request that https://login.com furnish an appropriate discharge macaroon to clear the 3p caveat +- https://login.com: + 1. Receiving the discharge request, + 1. Decrypts the ticket using the symmetric key they share with Alice + 1. Clear any caveats contained in the decrypted ticket + - `user=bob` caveat: Validate Bob's identity, for example using username/password + 1. Use the discharge secret from the decrypted ticket to issue a macaroon whose `nonce.kid` is the encrypted ticket + 1. Return this discharge token to Bob +- Bob: + 1. Having received the discharge macaroon, + 1. Makes a request to https://service.com including the https://service.com macaroon and discharge macaroon +- https://service.com: + 1. Receiving a request from Bob, + 1. Begins validation of the https://service.com macaroon + 1. Encountering a 3p caveat, + 1. Searches provided discharge macaroons for one whose `nonce.kid` matches the 3p caveat's encrypted ticket + 1. Decrypts the discharge secret from the VerifierKey using the tail signature from this point in the https://service.com macaroon validation process as a symmetric key + 1. Validates the discharge macaroon using the recovered discharge secret + 1. Clears remaining caveats in the https://service.com macaroon + 1. Processes the Bob's request, having successfully validated his macaroons. \ No newline at end of file From 15f03f7d867cb38e7f125a122b022548299ec68e Mon Sep 17 00:00:00 2001 From: btoews Date: Wed, 18 Oct 2023 12:57:02 -0600 Subject: [PATCH 03/15] spike out 3p server library --- go.mod | 1 + go.sum | 2 + macaroon.go | 10 ++ tp/README.md | 6 +- tp/server.go | 295 ++++++++++++++++++++++++++++++++++++++++++++++ tp/server_test.go | 125 ++++++++++++++++++++ tp/store.go | 126 ++++++++++++++++++++ tp/store_test.go | 90 ++++++++++++++ tp/tp.go | 22 ++++ 9 files changed, 676 insertions(+), 1 deletion(-) create mode 100644 tp/server.go create mode 100644 tp/server_test.go create mode 100644 tp/store.go create mode 100644 tp/store_test.go create mode 100644 tp/tp.go diff --git a/go.mod b/go.mod index 16c35e6..54a1453 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.20 require ( github.com/alecthomas/assert/v2 v2.3.0 github.com/google/uuid v1.3.0 + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/sirupsen/logrus v1.9.3 github.com/vmihailenco/msgpack/v5 v5.3.5 golang.org/x/crypto v0.12.0 diff --git a/go.sum b/go.sum index 16db810..f2ca0c2 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/macaroon.go b/macaroon.go index 6d757c7..6adb9ba 100644 --- a/macaroon.go +++ b/macaroon.go @@ -576,3 +576,13 @@ func (m *Macaroon) Expiration() time.Time { return ret } + +// String encoded token with `fm2_` prefix. +func (m *Macaroon) String() (string, error) { + tok, err := m.Encode() + if err != nil { + return "", err + } + + return encodeTokens(tok), nil +} diff --git a/tp/README.md b/tp/README.md index 4871e51..4769e7f 100644 --- a/tp/README.md +++ b/tp/README.md @@ -41,7 +41,11 @@ Set-Cookie: { "discharge": "base64 encoded discharge macaroon", - "poll": "/url/or/path/to/poll", + "poll_url": "/url/or/path/to/poll", + "user_interactive": { + "user_url": "", + "poll_url": "" + } } ``` diff --git a/tp/server.go b/tp/server.go new file mode 100644 index 0000000..fe6d65f --- /dev/null +++ b/tp/server.go @@ -0,0 +1,295 @@ +package tp + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + + "github.com/sirupsen/logrus" + "github.com/superfly/macaroon" +) + +type flowData struct { + tid string + ticket []byte + caveats []macaroon.Caveat + discharge *macaroon.Macaroon + log logrus.FieldLogger +} + +type TP struct { + Location string + Key macaroon.EncryptionKey + Store Store + Log logrus.FieldLogger +} + +func (tp *TP) InitRequestMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var jr jsonInitRequest + if err := json.NewDecoder(r.Body).Decode(&jr); err != nil { + tp.getLog(r).WithError(err).Warn("read/parse request") + http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) + return + } + + fd, r := tp.newFDOrError(w, r, "init", jr.Ticket) + if fd == nil { + return + } + + next.ServeHTTP(w, r) + }) +} + +func (tp *TP) HandlePollRequest(w http.ResponseWriter, r *http.Request) { + store := tp.storeOrError(w, r) + if store == nil { + return + } + + parts := strings.Split(r.URL.EscapedPath(), "/") + last := parts[len(parts)-1] + + sd, err := store.GetByPollSecret(last) + if err != nil || sd == nil { + tp.getLog(r).WithError(err).Warn("store lookup by poll secret") + http.Error(w, `{"error": "not found"}`, http.StatusNotFound) + return + } + + fd, r := tp.newFDOrError(w, r, "poll", sd.Ticket) + if fd == nil { + return + } + + if !sd.Ready { + tp.RespondError(w, r, http.StatusAccepted, "not ready") + return + } + + if err := store.Delete(sd); err != nil { + tp.getLog(r).WithError(err).Warn("store delete") + http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) + return + } + + if sd.Error != "" { + tp.RespondError(w, r, http.StatusOK, sd.Error) + return + } + + tp.respondDischarge(w, r, "discharge") +} + +func (tp *TP) UserRequestMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + store := tp.storeOrError(w, r) + if store == nil { + return + } + + userSecret, err := store.UserSecretFromRequest(r) + if err != nil || userSecret == "" { + tp.getLog(r).WithError(err).Warn("extracting user secret from request") + http.Error(w, `{"error": "not found"}`, http.StatusNotFound) + return + } + + sd, err := store.GetByUserSecret(userSecret) + if err != nil || sd == nil { + tp.getLog(r).WithError(err).Warn("store lookup by poll secret") + http.Error(w, `{"error": "not found"}`, http.StatusNotFound) + return + } + + fd, r := tp.newFDOrError(w, r, "poll", sd.Ticket) + if fd == nil { + return + } + + next.ServeHTTP(w, r) + }) +} + +func (tp *TP) RespondError(w http.ResponseWriter, r *http.Request, statusCode int, msg string) { + tp.respond(w, r, "error", statusCode, &jsonInitResponse{ + Error: msg, + }) +} + +func (tp *TP) RespondDischarge(w http.ResponseWriter, r *http.Request, caveats ...macaroon.Caveat) { + tp.respondDischarge(w, r, "immediate", caveats...) +} + +func (tp *TP) respondDischarge(w http.ResponseWriter, r *http.Request, respType string, caveats ...macaroon.Caveat) { + fd := tp.fdOrError(w, r) + if fd == nil { + return + } + + if err := fd.discharge.Add(caveats...); err != nil { + tp.getLog(r).WithError(err).Warn("attenuating discharge") + http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) + return + } + + tok, err := fd.discharge.String() + if err != nil { + tp.getLog(r).WithError(err).Warn("encode discharge") + http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) + return + } + + tp.respond(w, r, respType, http.StatusCreated, &jsonInitResponse{ + Discharge: tok, + }) +} + +func (tp *TP) RespondPoll(w http.ResponseWriter, r *http.Request) { + var ( + fd = tp.fdOrError(w, r) + store = tp.storeOrError(w, r) + ) + if fd == nil || store == nil { + return + } + + _, pollSecret, err := store.Put(&StoreData{Ticket: fd.ticket}) + if err != nil { + tp.getLog(r).WithError(err).Warn("store put") + http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) + return + } + + tp.respond(w, r, "poll", http.StatusCreated, &jsonInitResponse{ + PollURL: tp.url("/poll/" + url.PathEscape(pollSecret)), + }) +} + +func (tp *TP) RespondUserInteractive(w http.ResponseWriter, r *http.Request) { + var ( + fd = tp.fdOrError(w, r) + store = tp.storeOrError(w, r) + ) + if fd == nil || store == nil { + return + } + + userSecret, pollSecret, err := store.Put(&StoreData{Ticket: fd.ticket}) + if err != nil { + tp.getLog(r).WithError(err).Warn("store put") + http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) + return + } + + tp.respond(w, r, "user-interactive", http.StatusCreated, &jsonInitResponse{ + UserInteractive: &jsonUserInteractive{ + PollURL: tp.url("/poll/" + pollSecret), + UserURL: store.UserSecretToURL(userSecret), + }, + }) +} + +func (tp *TP) respond(w http.ResponseWriter, r *http.Request, respType string, statusCode int, jresp *jsonInitResponse) { + log := tp.getLog(r).WithFields(logrus.Fields{ + "status": statusCode, + "resp": respType, + }) + + if err := json.NewEncoder(w).Encode(jresp); err != nil { + log.WithError(err).Warn("writing response") + return + } + + log.Info() +} + +type contextKey string + +const contextKeyFlowData = contextKey("flow-data") + +func CaveatsFromRequest(r *http.Request) ([]macaroon.Caveat, error) { + if fd, ok := r.Context().Value(contextKeyFlowData).(*flowData); ok && fd != nil { + return fd.caveats, nil + } + + return nil, errors.New("middleware not called") +} + +func (tp *TP) newFDOrError(w http.ResponseWriter, r *http.Request, reqType string, ticket []byte) (*flowData, *http.Request) { + log := tp.getLog(r).WithField("req", reqType) + + caveats, discharge, err := macaroon.DischargeTicket(tp.Key, tp.Location, ticket) + if err != nil { + log.WithError(err).Warn("decrypt ticket") + http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) + return nil, r + } + + id := discharge.Nonce.UUID().String() + fd := &flowData{ + tid: id, + ticket: ticket, + caveats: caveats, + discharge: discharge, + log: log.WithField("tid", id), + } + + ctx := context.WithValue(r.Context(), contextKeyFlowData, fd) + return fd, r.WithContext(ctx) +} + +func (tp *TP) fdOrError(w http.ResponseWriter, r *http.Request) *flowData { + if fd, ok := r.Context().Value(contextKeyFlowData).(*flowData); ok && fd != nil { + return fd + } + + tp.getLog(r).Warn("middleware not called") + http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) + return nil +} + +func (tp *TP) storeOrError(w http.ResponseWriter, r *http.Request) Store { + if tp.Store != nil { + return tp.Store + } + + tp.getLog(r).Warn("missing store") + http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) + + return nil +} + +func (tp *TP) getLog(r *http.Request) logrus.FieldLogger { + if fd, ok := r.Context().Value(contextKeyFlowData).(*flowData); ok && fd.log != nil { + return fd.log + } + if tp.Log != nil { + return tp.Log + } + + log := logrus.New() + log.SetOutput(io.Discard) + return log +} + +func (tp *TP) url(path string) string { + if strings.HasSuffix(tp.Location, "/") { + return tp.Location + InitPath[1:] + path + } + return tp.Location + InitPath + path +} + +func randBytes(n int) []byte { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + panic(err) + } + return buf +} diff --git a/tp/server_test.go b/tp/server_test.go new file mode 100644 index 0000000..04975e9 --- /dev/null +++ b/tp/server_test.go @@ -0,0 +1,125 @@ +package tp + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/sirupsen/logrus" + "github.com/superfly/macaroon" +) + +func TestServer(t *testing.T) { + var ( + tp *TP + handleInit, handleUser http.Handler + ) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.EscapedPath() + + switch { + case path == InitPath: + tp.InitRequestMiddleware(handleInit).ServeHTTP(w, r) + case path == PollPath: + tp.HandlePollRequest(w, r) + case strings.HasPrefix(path, "/user/"): + tp.UserRequestMiddleware(handleUser).ServeHTTP(w, r) + default: + panic("huh?") + } + })) + + ms, err := NewMemoryStore(PrefixMunger("/user/"), 100) + assert.NoError(t, err) + + tp = &TP{ + Location: s.URL, + Key: macaroon.NewEncryptionKey(), + Store: ms, + Log: logrus.StandardLogger(), + } + + t.Run("immediate response", func(t *testing.T) { + handleInit = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := CaveatsFromRequest(r) + assert.NoError(t, err) + + tp.RespondDischarge(w, r, myCaveat("dis-cav")) + }) + + ticket, fp := genFP(t, tp, myCaveat("fp-cav")) + reqb, err := json.Marshal(&jsonInitRequest{Ticket: ticket}) + assert.NoError(t, err) + + res, err := s.Client().Post(s.URL+InitPath, "application/json", bytes.NewReader(reqb)) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + + var jres jsonInitResponse + assert.NoError(t, json.NewDecoder(res.Body).Decode(&jres)) + + cavs := checkFP(t, fp, jres.Discharge) + assert.Equal(t, []string{"fp-cav", "dis-cav"}, cavs) + }) +} + +var ( + fpLoc = "https://first-party" + fpKey = macaroon.NewSigningKey() + fpKID = []byte{1, 2, 3} +) + +func genFP(tb testing.TB, tp *TP, caveats ...macaroon.Caveat) ([]byte, string) { + tb.Helper() + + m, err := macaroon.New(fpKID, fpLoc, fpKey) + assert.NoError(tb, err) + + assert.NoError(tb, m.Add(caveats...)) + assert.NoError(tb, m.Add3P(tp.Key, tp.Location, caveats...)) + + tok, err := m.String() + assert.NoError(tb, err) + + ticket, err := m.ThirdPartyTicket(tp.Location) + assert.NoError(tb, err) + + return ticket, tok +} + +func checkFP(tb testing.TB, fp string, dis string) []string { + tb.Helper() + + fpb, err := macaroon.Parse(fp) + assert.NoError(tb, err) + + disb, err := macaroon.Parse(dis) + assert.NoError(tb, err) + + m, err := macaroon.Decode(fpb[0]) + assert.NoError(tb, err) + + cs, err := m.Verify(fpKey, disb, nil) + assert.NoError(tb, err) + + cavs := macaroon.GetCaveats[*myCaveat](cs) + ret := make([]string, len(cavs)) + for i := range cavs { + ret[i] = string(*cavs[i]) + } + + return ret +} + +type myCaveat string + +func init() { macaroon.RegisterCaveatType(new(myCaveat)) } + +func (c myCaveat) CaveatType() macaroon.CaveatType { return macaroon.CavMinUserDefined } +func (c myCaveat) Name() string { return "myCaveat" } +func (c myCaveat) Prohibits(f macaroon.Access) error { return nil } diff --git a/tp/store.go b/tp/store.go new file mode 100644 index 0000000..43a05d7 --- /dev/null +++ b/tp/store.go @@ -0,0 +1,126 @@ +package tp + +import ( + "encoding/hex" + "errors" + "net/http" + "strings" + "sync" + + lru "github.com/hashicorp/golang-lru/v2" + "golang.org/x/crypto/blake2b" +) + +type StoreData struct { + Ticket []byte + Ready bool + Error string +} + +type Store interface { + Put(*StoreData) (userSecret, pollSecret string, err error) + Delete(*StoreData) error + + GetByPollSecret(string) (*StoreData, error) + GetByUserSecret(string) (*StoreData, error) + + UserSecretMunger +} + +type UserSecretMunger interface { + UserSecretToURL(userSecret string) (url string) + UserSecretFromRequest(r *http.Request) (string, error) +} + +type MemoryStore struct { + UserSecretMunger + Cache *lru.Cache[string, *StoreData] + + _secret []byte + _initSecretOnce sync.Once +} + +func NewMemoryStore(m UserSecretMunger, size int) (*MemoryStore, error) { + cache, err := lru.New[string, *StoreData](size) + if err != nil { + return nil, err + } + + return &MemoryStore{ + Cache: cache, + UserSecretMunger: PrefixMunger("/user/"), + }, nil +} + +var _ Store = (*MemoryStore)(nil) + +var ( + errNotFound = errors.New("not found") +) + +func (s *MemoryStore) Put(sd *StoreData) (userSecret, pollSecret string, err error) { + userSecret, pollSecret = s.ticketSecrets(sd.Ticket) + s.Cache.Add("u"+digest(userSecret), sd) + s.Cache.Add("p"+digest(pollSecret), sd) + return +} + +func (s *MemoryStore) Delete(sd *StoreData) error { + userSecret, pollSecret := s.ticketSecrets(sd.Ticket) + s.Cache.Remove("u" + digest(userSecret)) + s.Cache.Remove("p" + digest(pollSecret)) + return nil +} + +func (s *MemoryStore) GetByPollSecret(pollSecret string) (*StoreData, error) { + if sd, ok := s.Cache.Get("p" + digest(pollSecret)); ok { + return sd, nil + } + return nil, errNotFound +} + +func (s *MemoryStore) GetByUserSecret(userSecret string) (*StoreData, error) { + if sd, ok := s.Cache.Get("u" + digest(userSecret)); ok { + return sd, nil + } + return nil, errNotFound +} + +func (s *MemoryStore) ticketSecrets(t []byte) (string, string) { + h, err := blake2b.New(32, s.secret()) + if err != nil { + panic(err) + } + if _, err = h.Write(t); err != nil { + panic(err) + } + d := h.Sum(nil) + + return hex.EncodeToString(d[:16]), hex.EncodeToString(d[16:]) +} + +func (s *MemoryStore) secret() []byte { + s._initSecretOnce.Do(func() { s._secret = randBytes(32) }) + return s._secret +} + +func digest(d string) string { + digest := blake2b.Sum256([]byte(d)) + return hex.EncodeToString(digest[:]) +} + +type PrefixMunger string + +var _ UserSecretMunger = PrefixMunger("") + +func (m PrefixMunger) UserSecretToURL(userSecret string) (url string) { + return string(m) + userSecret +} + +func (m PrefixMunger) UserSecretFromRequest(r *http.Request) (string, error) { + userSecret, hadPrefix := strings.CutPrefix(r.URL.EscapedPath(), string(m)) + if !hadPrefix { + return "", errors.New("bad request") + } + return userSecret, nil +} diff --git a/tp/store_test.go b/tp/store_test.go new file mode 100644 index 0000000..86c818a --- /dev/null +++ b/tp/store_test.go @@ -0,0 +1,90 @@ +package tp + +import ( + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestMemoryStoreSecrets(t *testing.T) { + ms, err := NewMemoryStore(PrefixMunger("/user/"), 100) + assert.NoError(t, err) + + assert.Equal(t, 32, len(ms.secret())) + + x, y := ms.ticketSecrets([]byte("hi")) + assert.Equal(t, 32, len(x)) + assert.Equal(t, 32, len(y)) + + a := &StoreData{Ticket: []byte("a")} + aUS, aPS, err := ms.Put(a) + assert.NoError(t, err) + + b := &StoreData{Ticket: []byte("b")} + bUS, bPS, err := ms.Put(b) + assert.NoError(t, err) + + sd, err := ms.GetByUserSecret(aUS) + assert.NoError(t, err) + assert.Equal(t, []byte("a"), sd.Ticket) + _, err = ms.GetByPollSecret(aUS) + assert.Equal(t, errNotFound, err) + + sd, err = ms.GetByPollSecret(aPS) + assert.NoError(t, err) + assert.Equal(t, []byte("a"), sd.Ticket) + _, err = ms.GetByUserSecret(aPS) + assert.Equal(t, errNotFound, err) + + sd, err = ms.GetByUserSecret(bUS) + assert.NoError(t, err) + assert.Equal(t, []byte("b"), sd.Ticket) + _, err = ms.GetByPollSecret(bUS) + assert.Equal(t, errNotFound, err) + + sd, err = ms.GetByPollSecret(bPS) + assert.NoError(t, err) + assert.Equal(t, []byte("b"), sd.Ticket) + _, err = ms.GetByUserSecret(bPS) + assert.Equal(t, errNotFound, err) + + assert.NoError(t, ms.Delete(a)) + + _, err = ms.GetByPollSecret(aPS) + assert.Equal(t, errNotFound, err) + _, err = ms.GetByUserSecret(aUS) + assert.Equal(t, errNotFound, err) + + sd, err = ms.GetByUserSecret(bUS) + assert.NoError(t, err) + assert.Equal(t, []byte("b"), sd.Ticket) + _, err = ms.GetByPollSecret(bUS) + assert.Equal(t, errNotFound, err) + + sd, err = ms.GetByPollSecret(bPS) + assert.NoError(t, err) + assert.Equal(t, []byte("b"), sd.Ticket) + _, err = ms.GetByUserSecret(bPS) + assert.Equal(t, errNotFound, err) + + bb := *b + bb.Ready = true + bbUS, bbPS, err := ms.Put(&bb) + assert.NoError(t, err) + assert.Equal(t, bUS, bbUS) + assert.Equal(t, bPS, bbPS) + + sd, err = ms.GetByUserSecret(bUS) + assert.NoError(t, err) + assert.Equal(t, []byte("b"), sd.Ticket) + assert.Equal(t, true, sd.Ready) + _, err = ms.GetByPollSecret(bUS) + assert.Equal(t, errNotFound, err) + + sd, err = ms.GetByPollSecret(bPS) + assert.NoError(t, err) + assert.Equal(t, []byte("b"), sd.Ticket) + assert.Equal(t, true, sd.Ready) + _, err = ms.GetByUserSecret(bPS) + assert.Equal(t, errNotFound, err) +} diff --git a/tp/tp.go b/tp/tp.go new file mode 100644 index 0000000..91b41ad --- /dev/null +++ b/tp/tp.go @@ -0,0 +1,22 @@ +package tp + +const ( + InitPath = "/.well-known/macfly/3p" + PollPath = "/.well-known/macfly/3p/poll" +) + +type jsonInitRequest struct { + Ticket []byte `json:"ticket,omitempty"` +} + +type jsonInitResponse struct { + Error string `json:"error,omitempty"` + Discharge string `json:"discharge,omitempty"` + PollURL string `json:"poll_url,omitempty"` + UserInteractive *jsonUserInteractive `json:"user_interactive,omitempty"` +} + +type jsonUserInteractive struct { + PollURL string `json:"poll_url,omitempty"` + UserURL string `json:"user_url,omitempty"` +} From ef933f3707f0893d6900be510ce3d35030ac5d71 Mon Sep 17 00:00:00 2001 From: btoews Date: Wed, 18 Oct 2023 17:33:10 -0600 Subject: [PATCH 04/15] tp client --- cid.go | 10 +-- tp/client.go | 220 ++++++++++++++++++++++++++++++++++++++++++++++ tp/server.go | 167 +++++++++++++++++++++++++++++------ tp/server_test.go | 125 -------------------------- tp/store.go | 8 +- tp/store_test.go | 6 +- tp/tp.go | 6 +- tp/tp_test.go | 187 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 563 insertions(+), 166 deletions(-) create mode 100644 tp/client.go delete mode 100644 tp/server_test.go create mode 100644 tp/tp_test.go diff --git a/cid.go b/cid.go index 9381093..af20a9f 100644 --- a/cid.go +++ b/cid.go @@ -38,20 +38,20 @@ func DischargeTicket(ka EncryptionKey, location string, ticket []byte) ([]Caveat // discharge macaroons will be proofs moving forward, but we need to be able to test the old non-proof dms too func dischargeTicket(ka EncryptionKey, location string, ticket []byte, issueProof bool) ([]Caveat, *Macaroon, error) { - ticketr, err := unseal(ka, ticket) + tRaw, err := unseal(ka, ticket) if err != nil { return nil, nil, fmt.Errorf("recover for discharge: ticket decrypt: %w", err) } - tticket := &wireTicket{} - if err = msgpack.Unmarshal(ticketr, tticket); err != nil { + tWire := &wireTicket{} + if err = msgpack.Unmarshal(tRaw, tWire); err != nil { return nil, nil, fmt.Errorf("recover for discharge: ticket decode: %w", err) } - dm, err := newMacaroon(ticket, location, tticket.DischargeKey, issueProof) + dm, err := newMacaroon(ticket, location, tWire.DischargeKey, issueProof) if err != nil { return nil, nil, err } - return tticket.Caveats.Caveats, dm, nil + return tWire.Caveats.Caveats, dm, nil } diff --git a/tp/client.go b/tp/client.go new file mode 100644 index 0000000..05e31e0 --- /dev/null +++ b/tp/client.go @@ -0,0 +1,220 @@ +package tp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/superfly/macaroon" + "github.com/superfly/macaroon/internal/merr" +) + +type Client struct { + FirstPartyLocation string + UserURLCallback func(url string) error + PollBackoffInitial time.Duration + PollBackoffNext func(time.Duration) time.Duration +} + +func (c *Client) FetchDischargeTokens(ctx context.Context, tokenHeader string, httpClient *http.Client) (string, error) { + if httpClient == nil { + httpClient = http.DefaultClient + } + + permTok, disToks, err := macaroon.ParsePermissionAndDischargeTokens(tokenHeader, c.FirstPartyLocation) + if err != nil { + return "", err + } + + perm, err := macaroon.Decode(permTok) + if err != nil { + return "", err + } + + tickets, err := perm.ThirdPartyTickets(disToks...) + if err != nil { + return "", err + } + + discharges := make([]*ClientDischarge, 0, len(tickets)) + wg := new(sync.WaitGroup) + + for tpLoc, ticket := range tickets { + discharge := &ClientDischarge{ + Client: c, + HTTP: httpClient, + ThirdPartyLocation: tpLoc, + Ticket: ticket, + Ctx: ctx, + } + discharges = append(discharges, discharge) + + wg.Add(1) + go func() { + defer wg.Done() + discharge.Run() + }() + } + + wg.Wait() + + if err := ctx.Err(); err != nil { + return "", err + } + + err = nil + for _, discharge := range discharges { + switch { + case discharge.Discharge != "": + tokenHeader = tokenHeader + "," + discharge.Discharge + case discharge.Error != nil: + err = merr.Append(err, discharge.Error) + default: + err = merr.Append(err, errors.New("shouldn't happen")) + } + } + + return tokenHeader, err +} + +type ClientDischarge struct { + Client *Client + HTTP *http.Client + ThirdPartyLocation string + Ticket []byte + Ctx context.Context + + // results + Discharge string + Error error +} + +func (cd *ClientDischarge) Run() { + jresp, err := cd.DoInitRequest() + + switch { + case err != nil: + cd.Error = err + case jresp.Discharge != "": + cd.Discharge = jresp.Discharge + case jresp.PollURL != "": + cd.Discharge, cd.Error = cd.DoPoll(jresp.PollURL) + case jresp.UserInteractive != nil: + cd.Discharge, cd.Error = cd.DoUserInteractive(jresp.UserInteractive) + default: + cd.Error = errors.New("bad discharge response") + } +} + +func (cd *ClientDischarge) DoInitRequest() (*jsonResponse, error) { + jreq := &jsonInitRequest{ + Ticket: cd.Ticket, + } + + breq, err := json.Marshal(jreq) + if err != nil { + return nil, err + } + + hreq, err := http.NewRequestWithContext(cd.Ctx, http.MethodPost, cd.url(""), bytes.NewReader(breq)) + if err != nil { + return nil, err + } + hreq.Header.Set("Content-Type", "application/json") + + hresp, err := cd.HTTP.Do(hreq) + if err != nil { + return nil, err + } + + var jresp jsonResponse + if err := json.NewDecoder(hresp.Body).Decode(&jresp); err != nil { + return nil, fmt.Errorf("bad response (%d): %w", hresp.StatusCode, err) + } + + if jresp.Error != "" { + return nil, &Error{hresp.StatusCode, jresp.Error} + } + + return &jresp, nil +} + +func (cd *ClientDischarge) DoPoll(pollURL string) (string, error) { + if pollURL == "" { + return "", errors.New("bad discharge response") + } + + req, err := http.NewRequestWithContext(cd.Ctx, http.MethodGet, pollURL, nil) + if err != nil { + return "", err + } + + var ( + bo = cd.Client.PollBackoffInitial + jresp jsonResponse + ) + +pollLoop: + for { + hresp, err := cd.HTTP.Do(req) + if err != nil { + return "", err + } + + if hresp.StatusCode == http.StatusAccepted { + select { + case <-time.After(bo): + bo = cd.Client.PollBackoffNext(bo) + continue pollLoop + case <-cd.Ctx.Done(): + return "", cd.Ctx.Err() + } + } + + if err := json.NewDecoder(hresp.Body).Decode(&jresp); err != nil { + return "", fmt.Errorf("bad response (%d): %w", hresp.StatusCode, err) + } + if jresp.Error != "" { + return "", &Error{hresp.StatusCode, jresp.Error} + } + if jresp.Discharge == "" { + return "", fmt.Errorf("bad response (%d): missing discharge", hresp.StatusCode) + } + + return jresp.Discharge, nil + } +} + +func (cd *ClientDischarge) DoUserInteractive(ui *jsonUserInteractive) (string, error) { + if ui.PollURL == "" || ui.UserURL == "" { + return "", errors.New("bad discharge response") + } + + if err := cd.Client.UserURLCallback(ui.UserURL); err != nil { + return "", err + } + + return cd.DoPoll(ui.PollURL) +} + +func (cd *ClientDischarge) url(path string) string { + if strings.HasSuffix(cd.ThirdPartyLocation, "/") { + return cd.ThirdPartyLocation + InitPath[1:] + path + } + return cd.ThirdPartyLocation + InitPath + path +} + +type Error struct { + StatusCode int + Msg string +} + +func (e Error) Error() string { + return fmt.Sprintf("tp error (%d): %s", e.StatusCode, e.Msg) +} diff --git a/tp/server.go b/tp/server.go index fe6d65f..6692539 100644 --- a/tp/server.go +++ b/tp/server.go @@ -15,7 +15,6 @@ import ( ) type flowData struct { - tid string ticket []byte caveats []macaroon.Caveat discharge *macaroon.Macaroon @@ -68,7 +67,7 @@ func (tp *TP) HandlePollRequest(w http.ResponseWriter, r *http.Request) { return } - if !sd.Ready { + if sd.ResponseBody == nil || sd.ResponseStatus == 0 { tp.RespondError(w, r, http.StatusAccepted, "not ready") return } @@ -79,12 +78,18 @@ func (tp *TP) HandlePollRequest(w http.ResponseWriter, r *http.Request) { return } - if sd.Error != "" { - tp.RespondError(w, r, http.StatusOK, sd.Error) + log := tp.getLog(r).WithFields(logrus.Fields{ + "status": sd.ResponseStatus, + "resp": "discharge", + }) + + w.WriteHeader(sd.ResponseStatus) + if _, err := w.Write(sd.ResponseBody); err != nil { + log.WithError(err).Warn("writing response") return } - tp.respondDischarge(w, r, "discharge") + log.Info() } func (tp *TP) UserRequestMiddleware(next http.Handler) http.Handler { @@ -118,7 +123,7 @@ func (tp *TP) UserRequestMiddleware(next http.Handler) http.Handler { } func (tp *TP) RespondError(w http.ResponseWriter, r *http.Request, statusCode int, msg string) { - tp.respond(w, r, "error", statusCode, &jsonInitResponse{ + tp.respond(w, r, "error", statusCode, &jsonResponse{ Error: msg, }) } @@ -146,62 +151,163 @@ func (tp *TP) respondDischarge(w http.ResponseWriter, r *http.Request, respType return } - tp.respond(w, r, respType, http.StatusCreated, &jsonInitResponse{ + tp.respond(w, r, respType, http.StatusCreated, &jsonResponse{ Discharge: tok, }) } -func (tp *TP) RespondPoll(w http.ResponseWriter, r *http.Request) { +func (tp *TP) RespondPoll(w http.ResponseWriter, r *http.Request) string { var ( fd = tp.fdOrError(w, r) store = tp.storeOrError(w, r) ) if fd == nil || store == nil { - return + return "" } _, pollSecret, err := store.Put(&StoreData{Ticket: fd.ticket}) if err != nil { tp.getLog(r).WithError(err).Warn("store put") http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) - return + return "" } - tp.respond(w, r, "poll", http.StatusCreated, &jsonInitResponse{ + tp.respond(w, r, "poll", http.StatusCreated, &jsonResponse{ PollURL: tp.url("/poll/" + url.PathEscape(pollSecret)), }) + + return pollSecret +} + +func (tp *TP) DischargePoll(pollSecret string, caveats ...macaroon.Caveat) error { + return tp.dischargePoller(pollSecret, "", caveats...) } -func (tp *TP) RespondUserInteractive(w http.ResponseWriter, r *http.Request) { +func (tp *TP) AbortPoll(pollSecret string, message string) error { + return tp.abortPoller(pollSecret, "", message) +} + +func (tp *TP) RespondUserInteractive(w http.ResponseWriter, r *http.Request) string { var ( fd = tp.fdOrError(w, r) store = tp.storeOrError(w, r) ) if fd == nil || store == nil { - return + return "" } userSecret, pollSecret, err := store.Put(&StoreData{Ticket: fd.ticket}) if err != nil { tp.getLog(r).WithError(err).Warn("store put") http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) - return + return "" } - tp.respond(w, r, "user-interactive", http.StatusCreated, &jsonInitResponse{ + tp.respond(w, r, "user-interactive", http.StatusCreated, &jsonResponse{ UserInteractive: &jsonUserInteractive{ PollURL: tp.url("/poll/" + pollSecret), UserURL: store.UserSecretToURL(userSecret), }, }) + + return userSecret } -func (tp *TP) respond(w http.ResponseWriter, r *http.Request, respType string, statusCode int, jresp *jsonInitResponse) { +func (tp *TP) DischargeUserInteractive(userSecret string, caveats ...macaroon.Caveat) error { + return tp.dischargePoller("", userSecret, caveats...) +} + +func (tp *TP) AbortUserInteractive(userSecret string, message string) error { + return tp.abortPoller("", userSecret, message) +} + +func (tp *TP) dischargePoller(pollSecret, userSecret string, caveats ...macaroon.Caveat) error { + if tp.Store == nil { + return errors.New("no store") + } + + var ( + sd *StoreData + err error + ) + if pollSecret != "" { + sd, err = tp.Store.GetByPollSecret(pollSecret) + } else { + sd, err = tp.Store.GetByUserSecret(userSecret) + } + if err != nil { + return err + } + + fd, err := tp.newFD(nil, "background", sd.Ticket) + if err != nil { + return err + } + + if err := fd.discharge.Add(caveats...); err != nil { + return err + } + + tok, err := fd.discharge.String() + if err != nil { + return err + } + + jresp, err := json.Marshal(&jsonResponse{Discharge: tok}) + if err != nil { + return err + } + + sd.ResponseBody = jresp + sd.ResponseStatus = http.StatusOK + + if _, _, err := tp.Store.Put(sd); err != nil { + return err + } + + return nil +} + +func (tp *TP) abortPoller(pollSecret, userSecret string, message string) error { + if tp.Store == nil { + return errors.New("no store") + } + + var ( + sd *StoreData + err error + ) + if pollSecret != "" { + sd, err = tp.Store.GetByPollSecret(pollSecret) + } else { + sd, err = tp.Store.GetByUserSecret(userSecret) + } + if err != nil { + return err + } + + jresp, err := json.Marshal(&jsonResponse{Error: message}) + if err != nil { + return err + } + + sd.ResponseBody = jresp + sd.ResponseStatus = http.StatusOK + + if _, _, err := tp.Store.Put(sd); err != nil { + return err + } + + return nil +} + +func (tp *TP) respond(w http.ResponseWriter, r *http.Request, respType string, statusCode int, jresp *jsonResponse) { log := tp.getLog(r).WithFields(logrus.Fields{ "status": statusCode, "resp": respType, }) + w.WriteHeader(statusCode) if err := json.NewEncoder(w).Encode(jresp); err != nil { log.WithError(err).Warn("writing response") return @@ -223,26 +329,33 @@ func CaveatsFromRequest(r *http.Request) ([]macaroon.Caveat, error) { } func (tp *TP) newFDOrError(w http.ResponseWriter, r *http.Request, reqType string, ticket []byte) (*flowData, *http.Request) { + fd, err := tp.newFD(r, reqType, ticket) + if err != nil { + tp.getLog(r).WithError(err).Warn("recover ticket") + http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) + return nil, r + } + + ctx := context.WithValue(r.Context(), contextKeyFlowData, fd) + return fd, r.WithContext(ctx) +} + +func (tp *TP) newFD(r *http.Request, reqType string, ticket []byte) (*flowData, error) { log := tp.getLog(r).WithField("req", reqType) caveats, discharge, err := macaroon.DischargeTicket(tp.Key, tp.Location, ticket) if err != nil { - log.WithError(err).Warn("decrypt ticket") - http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError) - return nil, r + return nil, err } - id := discharge.Nonce.UUID().String() fd := &flowData{ - tid: id, ticket: ticket, caveats: caveats, discharge: discharge, - log: log.WithField("tid", id), + log: log.WithField("tid", digest(ticket)), } - ctx := context.WithValue(r.Context(), contextKeyFlowData, fd) - return fd, r.WithContext(ctx) + return fd, nil } func (tp *TP) fdOrError(w http.ResponseWriter, r *http.Request) *flowData { @@ -267,8 +380,10 @@ func (tp *TP) storeOrError(w http.ResponseWriter, r *http.Request) Store { } func (tp *TP) getLog(r *http.Request) logrus.FieldLogger { - if fd, ok := r.Context().Value(contextKeyFlowData).(*flowData); ok && fd.log != nil { - return fd.log + if r != nil { + if fd, ok := r.Context().Value(contextKeyFlowData).(*flowData); ok && fd.log != nil { + return fd.log + } } if tp.Log != nil { return tp.Log diff --git a/tp/server_test.go b/tp/server_test.go deleted file mode 100644 index 04975e9..0000000 --- a/tp/server_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package tp - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/alecthomas/assert/v2" - "github.com/sirupsen/logrus" - "github.com/superfly/macaroon" -) - -func TestServer(t *testing.T) { - var ( - tp *TP - handleInit, handleUser http.Handler - ) - - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.EscapedPath() - - switch { - case path == InitPath: - tp.InitRequestMiddleware(handleInit).ServeHTTP(w, r) - case path == PollPath: - tp.HandlePollRequest(w, r) - case strings.HasPrefix(path, "/user/"): - tp.UserRequestMiddleware(handleUser).ServeHTTP(w, r) - default: - panic("huh?") - } - })) - - ms, err := NewMemoryStore(PrefixMunger("/user/"), 100) - assert.NoError(t, err) - - tp = &TP{ - Location: s.URL, - Key: macaroon.NewEncryptionKey(), - Store: ms, - Log: logrus.StandardLogger(), - } - - t.Run("immediate response", func(t *testing.T) { - handleInit = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := CaveatsFromRequest(r) - assert.NoError(t, err) - - tp.RespondDischarge(w, r, myCaveat("dis-cav")) - }) - - ticket, fp := genFP(t, tp, myCaveat("fp-cav")) - reqb, err := json.Marshal(&jsonInitRequest{Ticket: ticket}) - assert.NoError(t, err) - - res, err := s.Client().Post(s.URL+InitPath, "application/json", bytes.NewReader(reqb)) - assert.NoError(t, err) - assert.Equal(t, http.StatusOK, res.StatusCode) - - var jres jsonInitResponse - assert.NoError(t, json.NewDecoder(res.Body).Decode(&jres)) - - cavs := checkFP(t, fp, jres.Discharge) - assert.Equal(t, []string{"fp-cav", "dis-cav"}, cavs) - }) -} - -var ( - fpLoc = "https://first-party" - fpKey = macaroon.NewSigningKey() - fpKID = []byte{1, 2, 3} -) - -func genFP(tb testing.TB, tp *TP, caveats ...macaroon.Caveat) ([]byte, string) { - tb.Helper() - - m, err := macaroon.New(fpKID, fpLoc, fpKey) - assert.NoError(tb, err) - - assert.NoError(tb, m.Add(caveats...)) - assert.NoError(tb, m.Add3P(tp.Key, tp.Location, caveats...)) - - tok, err := m.String() - assert.NoError(tb, err) - - ticket, err := m.ThirdPartyTicket(tp.Location) - assert.NoError(tb, err) - - return ticket, tok -} - -func checkFP(tb testing.TB, fp string, dis string) []string { - tb.Helper() - - fpb, err := macaroon.Parse(fp) - assert.NoError(tb, err) - - disb, err := macaroon.Parse(dis) - assert.NoError(tb, err) - - m, err := macaroon.Decode(fpb[0]) - assert.NoError(tb, err) - - cs, err := m.Verify(fpKey, disb, nil) - assert.NoError(tb, err) - - cavs := macaroon.GetCaveats[*myCaveat](cs) - ret := make([]string, len(cavs)) - for i := range cavs { - ret[i] = string(*cavs[i]) - } - - return ret -} - -type myCaveat string - -func init() { macaroon.RegisterCaveatType(new(myCaveat)) } - -func (c myCaveat) CaveatType() macaroon.CaveatType { return macaroon.CavMinUserDefined } -func (c myCaveat) Name() string { return "myCaveat" } -func (c myCaveat) Prohibits(f macaroon.Access) error { return nil } diff --git a/tp/store.go b/tp/store.go index 43a05d7..b29d60e 100644 --- a/tp/store.go +++ b/tp/store.go @@ -12,9 +12,9 @@ import ( ) type StoreData struct { - Ticket []byte - Ready bool - Error string + Ticket []byte + ResponseStatus int + ResponseBody []byte } type Store interface { @@ -104,7 +104,7 @@ func (s *MemoryStore) secret() []byte { return s._secret } -func digest(d string) string { +func digest[T string | []byte](d T) string { digest := blake2b.Sum256([]byte(d)) return hex.EncodeToString(digest[:]) } diff --git a/tp/store_test.go b/tp/store_test.go index 86c818a..4c4680c 100644 --- a/tp/store_test.go +++ b/tp/store_test.go @@ -68,7 +68,7 @@ func TestMemoryStoreSecrets(t *testing.T) { assert.Equal(t, errNotFound, err) bb := *b - bb.Ready = true + bb.ResponseBody = []byte{1, 2, 3} bbUS, bbPS, err := ms.Put(&bb) assert.NoError(t, err) assert.Equal(t, bUS, bbUS) @@ -77,14 +77,14 @@ func TestMemoryStoreSecrets(t *testing.T) { sd, err = ms.GetByUserSecret(bUS) assert.NoError(t, err) assert.Equal(t, []byte("b"), sd.Ticket) - assert.Equal(t, true, sd.Ready) + assert.Equal(t, []byte{1, 2, 3}, sd.ResponseBody) _, err = ms.GetByPollSecret(bUS) assert.Equal(t, errNotFound, err) sd, err = ms.GetByPollSecret(bPS) assert.NoError(t, err) assert.Equal(t, []byte("b"), sd.Ticket) - assert.Equal(t, true, sd.Ready) + assert.Equal(t, []byte{1, 2, 3}, sd.ResponseBody) _, err = ms.GetByUserSecret(bPS) assert.Equal(t, errNotFound, err) } diff --git a/tp/tp.go b/tp/tp.go index 91b41ad..024e195 100644 --- a/tp/tp.go +++ b/tp/tp.go @@ -1,15 +1,15 @@ package tp const ( - InitPath = "/.well-known/macfly/3p" - PollPath = "/.well-known/macfly/3p/poll" + InitPath = "/.well-known/macfly/3p" + PollPathPrefix = "/.well-known/macfly/3p/poll/" ) type jsonInitRequest struct { Ticket []byte `json:"ticket,omitempty"` } -type jsonInitResponse struct { +type jsonResponse struct { Error string `json:"error,omitempty"` Discharge string `json:"discharge,omitempty"` PollURL string `json:"poll_url,omitempty"` diff --git a/tp/tp_test.go b/tp/tp_test.go new file mode 100644 index 0000000..1e449e4 --- /dev/null +++ b/tp/tp_test.go @@ -0,0 +1,187 @@ +package tp + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/alecthomas/assert/v2" + "github.com/sirupsen/logrus" + "github.com/superfly/macaroon" +) + +func TestServer(t *testing.T) { + var ( + tp *TP + handleInit, handleUser http.Handler + ) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.EscapedPath() + + switch { + case path == InitPath: + tp.InitRequestMiddleware(handleInit).ServeHTTP(w, r) + case strings.HasPrefix(path, PollPathPrefix): + tp.HandlePollRequest(w, r) + case strings.HasPrefix(path, "/user/"): + tp.UserRequestMiddleware(handleUser).ServeHTTP(w, r) + default: + panic(r.URL.EscapedPath()) + } + })) + + ms, err := NewMemoryStore(PrefixMunger("/user/"), 100) + assert.NoError(t, err) + + tp = &TP{ + Location: s.URL, + Key: macaroon.NewEncryptionKey(), + Store: ms, + Log: logrus.StandardLogger(), + } + + t.Run("immediate response", func(t *testing.T) { + handleInit = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := CaveatsFromRequest(r) + assert.NoError(t, err) + + tp.RespondDischarge(w, r, myCaveat("dis-cav")) + }) + + hdr := genFP(t, tp, myCaveat("fp-cav")) + c := &Client{FirstPartyLocation: fpLoc} + hdr, err = c.FetchDischargeTokens(context.Background(), hdr, nil) + assert.NoError(t, err) + cavs := checkFP(t, hdr) + assert.Equal(t, []string{"fp-cav", "dis-cav"}, cavs) + }) + + t.Run("poll response", func(t *testing.T) { + pollSecret := "" + pollSecretSet := make(chan struct{}) + + handleInit = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := CaveatsFromRequest(r) + assert.NoError(t, err) + + pollSecret = tp.RespondPoll(w, r) + close(pollSecretSet) + }) + + hdr := genFP(t, tp, myCaveat("fp-cav")) + + c := &Client{ + FirstPartyLocation: fpLoc, + PollBackoffInitial: 10 * time.Millisecond, + PollBackoffNext: func(d time.Duration) time.Duration { return 10 * time.Second }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + go func() { + select { + case <-pollSecretSet: + select { + case <-time.After(5 * time.Millisecond): + assert.NoError(t, tp.DischargePoll(pollSecret, myCaveat("dis-cav"))) + case <-ctx.Done(): + panic("oh no") + } + case <-ctx.Done(): + panic("oh no") + } + }() + + hdr, err = c.FetchDischargeTokens(ctx, hdr, nil) + assert.NoError(t, err) + cavs := checkFP(t, hdr) + assert.Equal(t, []string{"fp-cav", "dis-cav"}, cavs) + }) + + t.Run("user interactive response", func(t *testing.T) { + userSecret := "" + + handleInit = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := CaveatsFromRequest(r) + assert.NoError(t, err) + + userSecret = tp.RespondUserInteractive(w, r) + }) + + hdr := genFP(t, tp, myCaveat("fp-cav")) + + c := &Client{ + FirstPartyLocation: fpLoc, + PollBackoffInitial: 10 * time.Millisecond, + PollBackoffNext: func(d time.Duration) time.Duration { return 10 * time.Second }, + UserURLCallback: func(url string) error { + time.Sleep(10 * time.Millisecond) + assert.NoError(t, tp.DischargeUserInteractive(userSecret, myCaveat("dis-cav"))) + return nil + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + hdr, err = c.FetchDischargeTokens(ctx, hdr, nil) + assert.NoError(t, err) + cavs := checkFP(t, hdr) + assert.Equal(t, []string{"fp-cav", "dis-cav"}, cavs) + }) +} + +var ( + fpLoc = "https://first-party" + fpKey = macaroon.NewSigningKey() + fpKID = []byte{1, 2, 3} +) + +func genFP(tb testing.TB, tp *TP, caveats ...macaroon.Caveat) string { + tb.Helper() + + m, err := macaroon.New(fpKID, fpLoc, fpKey) + assert.NoError(tb, err) + + assert.NoError(tb, m.Add(caveats...)) + assert.NoError(tb, m.Add3P(tp.Key, tp.Location, caveats...)) + + tok, err := m.Encode() + assert.NoError(tb, err) + + return macaroon.ToAuthorizationHeader(tok) +} + +func checkFP(tb testing.TB, hdr string) []string { + tb.Helper() + + fpb, dissb, err := macaroon.ParsePermissionAndDischargeTokens(hdr, fpLoc) + assert.NoError(tb, err) + + m, err := macaroon.Decode(fpb) + assert.NoError(tb, err) + + cs, err := m.Verify(fpKey, dissb, nil) + assert.NoError(tb, err) + + cavs := macaroon.GetCaveats[*myCaveat](cs) + ret := make([]string, len(cavs)) + for i := range cavs { + ret[i] = string(*cavs[i]) + } + + return ret +} + +type myCaveat string + +func init() { macaroon.RegisterCaveatType(new(myCaveat)) } + +func (c myCaveat) CaveatType() macaroon.CaveatType { return macaroon.CavMinUserDefined } +func (c myCaveat) Name() string { return "myCaveat" } +func (c myCaveat) Prohibits(f macaroon.Access) error { return nil } From d822456c334a7d3e5565b9028d8a4caa212d1d19 Mon Sep 17 00:00:00 2001 From: btoews Date: Thu, 19 Oct 2023 08:06:31 -0600 Subject: [PATCH 05/15] example test --- tp/client.go | 168 ++++++++++++++++++++--------------- tp/immediate_example_test.go | 88 ++++++++++++++++++ tp/tp_test.go | 108 ++++++++++++++++------ 3 files changed, 265 insertions(+), 99 deletions(-) create mode 100644 tp/immediate_example_test.go diff --git a/tp/client.go b/tp/client.go index 05e31e0..ac4d8e2 100644 --- a/tp/client.go +++ b/tp/client.go @@ -15,18 +15,33 @@ import ( "github.com/superfly/macaroon/internal/merr" ) +type ClientOption func(*Client) + type Client struct { + // Location identifier for the party that issued the first party macaroon. FirstPartyLocation string - UserURLCallback func(url string) error - PollBackoffInitial time.Duration - PollBackoffNext func(time.Duration) time.Duration -} -func (c *Client) FetchDischargeTokens(ctx context.Context, tokenHeader string, httpClient *http.Client) (string, error) { - if httpClient == nil { - httpClient = http.DefaultClient - } + // HTTP client to use for requests to third parties. Third parties may try + // to set cookies to expedite future discharge flows. This may be + // facilitated by setting the http.Client's Jar field. With cookies enabled + // it's important to use a different cookie jar and hence client when + // fetching discharge tokens for multiple users. + HTTP *http.Client + + // Function to call when when the third party needs to interact with the + // end-user directly. The provided URL should be opened in the user's + // browser if possible. Otherwise it should be displayed to the user and + // they should be instructed to open it themselves. (Optional, but attempts + // at user-interactive discharge flow will fail) + UserURLCallback func(url string) error + + // A function determining how long to wait before making the next request + // when polling the third party to see if a discharge is ready. This is + // called the first time with a zero duration. (Optional) + PollBackoffNext func(lastBO time.Duration) (nextBO time.Duration) +} +func (c *Client) FetchDischargeTokens(ctx context.Context, tokenHeader string) (string, error) { permTok, disToks, err := macaroon.ParsePermissionAndDischargeTokens(tokenHeader, c.FirstPartyLocation) if err != nil { return "", err @@ -42,79 +57,55 @@ func (c *Client) FetchDischargeTokens(ctx context.Context, tokenHeader string, h return "", err } - discharges := make([]*ClientDischarge, 0, len(tickets)) - wg := new(sync.WaitGroup) + var ( + wg sync.WaitGroup + m sync.Mutex + combinedErr error + ) for tpLoc, ticket := range tickets { - discharge := &ClientDischarge{ - Client: c, - HTTP: httpClient, - ThirdPartyLocation: tpLoc, - Ticket: ticket, - Ctx: ctx, - } - discharges = append(discharges, discharge) - wg.Add(1) - go func() { + go func(tpLoc string, ticket []byte) { defer wg.Done() - discharge.Run() - }() - } - wg.Wait() + dis, err := c.fetchDischargeToken(ctx, tpLoc, ticket) - if err := ctx.Err(); err != nil { - return "", err - } + m.Lock() + defer m.Unlock() - err = nil - for _, discharge := range discharges { - switch { - case discharge.Discharge != "": - tokenHeader = tokenHeader + "," + discharge.Discharge - case discharge.Error != nil: - err = merr.Append(err, discharge.Error) - default: - err = merr.Append(err, errors.New("shouldn't happen")) - } + if err != nil { + combinedErr = merr.Append(combinedErr, err) + } else { + tokenHeader = tokenHeader + "," + dis + } + }(tpLoc, ticket) } - return tokenHeader, err -} - -type ClientDischarge struct { - Client *Client - HTTP *http.Client - ThirdPartyLocation string - Ticket []byte - Ctx context.Context + wg.Wait() - // results - Discharge string - Error error + return tokenHeader, combinedErr } -func (cd *ClientDischarge) Run() { - jresp, err := cd.DoInitRequest() +func (c *Client) fetchDischargeToken(ctx context.Context, thirdPartyLocation string, ticket []byte) (string, error) { + jresp, err := c.doInitRequest(ctx, thirdPartyLocation, ticket) switch { case err != nil: - cd.Error = err + return "", err case jresp.Discharge != "": - cd.Discharge = jresp.Discharge + return jresp.Discharge, nil case jresp.PollURL != "": - cd.Discharge, cd.Error = cd.DoPoll(jresp.PollURL) + return c.doPoll(ctx, jresp.PollURL) case jresp.UserInteractive != nil: - cd.Discharge, cd.Error = cd.DoUserInteractive(jresp.UserInteractive) + return c.doUserInteractive(ctx, jresp.UserInteractive) default: - cd.Error = errors.New("bad discharge response") + return "", errors.New("bad discharge response") } } -func (cd *ClientDischarge) DoInitRequest() (*jsonResponse, error) { +func (c *Client) doInitRequest(ctx context.Context, thirdPartyLocation string, ticket []byte) (*jsonResponse, error) { jreq := &jsonInitRequest{ - Ticket: cd.Ticket, + Ticket: ticket, } breq, err := json.Marshal(jreq) @@ -122,13 +113,13 @@ func (cd *ClientDischarge) DoInitRequest() (*jsonResponse, error) { return nil, err } - hreq, err := http.NewRequestWithContext(cd.Ctx, http.MethodPost, cd.url(""), bytes.NewReader(breq)) + hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, initURL(thirdPartyLocation), bytes.NewReader(breq)) if err != nil { return nil, err } hreq.Header.Set("Content-Type", "application/json") - hresp, err := cd.HTTP.Do(hreq) + hresp, err := c.http().Do(hreq) if err != nil { return nil, err } @@ -145,35 +136,36 @@ func (cd *ClientDischarge) DoInitRequest() (*jsonResponse, error) { return &jresp, nil } -func (cd *ClientDischarge) DoPoll(pollURL string) (string, error) { +func (c *Client) doPoll(ctx context.Context, pollURL string) (string, error) { if pollURL == "" { return "", errors.New("bad discharge response") } - req, err := http.NewRequestWithContext(cd.Ctx, http.MethodGet, pollURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil) if err != nil { return "", err } var ( - bo = cd.Client.PollBackoffInitial + bo time.Duration jresp jsonResponse ) pollLoop: for { - hresp, err := cd.HTTP.Do(req) + hresp, err := c.http().Do(req) if err != nil { return "", err } if hresp.StatusCode == http.StatusAccepted { + bo = c.nextBO(bo) + select { case <-time.After(bo): - bo = cd.Client.PollBackoffNext(bo) continue pollLoop - case <-cd.Ctx.Done(): - return "", cd.Ctx.Err() + case <-ctx.Done(): + return "", ctx.Err() } } @@ -191,23 +183,51 @@ pollLoop: } } -func (cd *ClientDischarge) DoUserInteractive(ui *jsonUserInteractive) (string, error) { +func (c *Client) doUserInteractive(ctx context.Context, ui *jsonUserInteractive) (string, error) { if ui.PollURL == "" || ui.UserURL == "" { return "", errors.New("bad discharge response") } + if c.UserURLCallback == nil { + return "", errors.New("missing user-url callback") + } - if err := cd.Client.UserURLCallback(ui.UserURL); err != nil { + if err := c.openUserInteractiveURL(ui.UserURL); err != nil { return "", err } - return cd.DoPoll(ui.PollURL) + return c.doPoll(ctx, ui.PollURL) +} + +func (c *Client) nextBO(lastBO time.Duration) time.Duration { + if c.PollBackoffNext != nil { + return c.PollBackoffNext(lastBO) + } + if lastBO == 0 { + return time.Second + } + return 2 * lastBO +} + +func (c *Client) openUserInteractiveURL(url string) error { + if c.UserURLCallback != nil { + return c.UserURLCallback(url) + } + + return errors.New("client not configured for opening URLs") +} + +func (c *Client) http() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return http.DefaultClient } -func (cd *ClientDischarge) url(path string) string { - if strings.HasSuffix(cd.ThirdPartyLocation, "/") { - return cd.ThirdPartyLocation + InitPath[1:] + path +func initURL(location string) string { + if strings.HasSuffix(location, "/") { + return location + InitPath[1:] } - return cd.ThirdPartyLocation + InitPath + path + return location + InitPath } type Error struct { diff --git a/tp/immediate_example_test.go b/tp/immediate_example_test.go new file mode 100644 index 0000000..8dacc1d --- /dev/null +++ b/tp/immediate_example_test.go @@ -0,0 +1,88 @@ +package tp + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "time" + + "github.com/sirupsen/logrus" + "github.com/superfly/macaroon" +) + +type immediateSever struct { + tp *TP + *http.ServeMux +} + +func newImmediateServer(tp *TP) *immediateSever { + is := &immediateSever{ + tp: tp, + ServeMux: http.NewServeMux(), + } + + is.Handle(InitPath, tp.InitRequestMiddleware(http.HandlerFunc(is.handleInitRequest))) + + return is +} + +func (is *immediateSever) handleInitRequest(w http.ResponseWriter, r *http.Request) { + if username, password, _ := r.BasicAuth(); username != "mulder" || password != "trustno1" { + is.tp.RespondError(w, r, http.StatusUnauthorized, "bad client authentication") + return + } + + // discharge token will be valid for one minute + caveat := &macaroon.ValidityWindow{ + NotBefore: time.Now().Unix(), + NotAfter: time.Now().Add(time.Minute).Unix(), + } + + is.tp.RespondDischarge(w, r, caveat) +} + +var immediateServerKey = macaroon.NewEncryptionKey() + +func ExampleTP_RespondDischarge() { + tp := &TP{ + Key: immediateServerKey, + Log: logrus.StandardLogger(), + } + + is := newImmediateServer(tp) + + hs := httptest.NewServer(is) + defer hs.Close() + + tp.Location = hs.URL + + // simulate user getting/having a 1st party macaroon with a 3rd party caveat + firstPartyMacaroon, err := getFirstPartyMacaroonWithThirdPartyCaveat( + tp.Location, + immediateServerKey, + ) + if err != nil { + panic(err) + } + + _, err = validateFirstPartyMacaroon(firstPartyMacaroon) + fmt.Printf("validation error without 3p discharge token: %v\n", err) + + client := &Client{ + FirstPartyLocation: firstPartyLocation, + HTTP: basicAuthClient("mulder", "trustno1"), + } + + firstPartyMacaroon, err = client.FetchDischargeTokens(context.Background(), firstPartyMacaroon) + if err != nil { + panic(err) + } + + _, err = validateFirstPartyMacaroon(firstPartyMacaroon) + fmt.Printf("validation error with 3p discharge token: %v\n", err) + + // Output: + // validation error without 3p discharge token: no matching discharge token + // validation error with 3p discharge token: +} diff --git a/tp/tp_test.go b/tp/tp_test.go index 1e449e4..0c728d2 100644 --- a/tp/tp_test.go +++ b/tp/tp_test.go @@ -13,7 +13,7 @@ import ( "github.com/superfly/macaroon" ) -func TestServer(t *testing.T) { +func TestTP(t *testing.T) { var ( tp *TP handleInit, handleUser http.Handler @@ -33,6 +33,7 @@ func TestServer(t *testing.T) { panic(r.URL.EscapedPath()) } })) + t.Cleanup(s.Close) ms, err := NewMemoryStore(PrefixMunger("/user/"), 100) assert.NoError(t, err) @@ -53,8 +54,8 @@ func TestServer(t *testing.T) { }) hdr := genFP(t, tp, myCaveat("fp-cav")) - c := &Client{FirstPartyLocation: fpLoc} - hdr, err = c.FetchDischargeTokens(context.Background(), hdr, nil) + c := &Client{FirstPartyLocation: firstPartyLocation} + hdr, err = c.FetchDischargeTokens(context.Background(), hdr) assert.NoError(t, err) cavs := checkFP(t, hdr) assert.Equal(t, []string{"fp-cav", "dis-cav"}, cavs) @@ -75,9 +76,13 @@ func TestServer(t *testing.T) { hdr := genFP(t, tp, myCaveat("fp-cav")) c := &Client{ - FirstPartyLocation: fpLoc, - PollBackoffInitial: 10 * time.Millisecond, - PollBackoffNext: func(d time.Duration) time.Duration { return 10 * time.Second }, + FirstPartyLocation: firstPartyLocation, + PollBackoffNext: func(last time.Duration) time.Duration { + if last == 0 { + return 10 * time.Millisecond + } + return 10 * time.Second + }, } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -97,7 +102,7 @@ func TestServer(t *testing.T) { } }() - hdr, err = c.FetchDischargeTokens(ctx, hdr, nil) + hdr, err = c.FetchDischargeTokens(ctx, hdr) assert.NoError(t, err) cavs := checkFP(t, hdr) assert.Equal(t, []string{"fp-cav", "dis-cav"}, cavs) @@ -116,9 +121,13 @@ func TestServer(t *testing.T) { hdr := genFP(t, tp, myCaveat("fp-cav")) c := &Client{ - FirstPartyLocation: fpLoc, - PollBackoffInitial: 10 * time.Millisecond, - PollBackoffNext: func(d time.Duration) time.Duration { return 10 * time.Second }, + FirstPartyLocation: firstPartyLocation, + PollBackoffNext: func(last time.Duration) time.Duration { + if last == 0 { + return 10 * time.Millisecond + } + return 10 * time.Second + }, UserURLCallback: func(url string) error { time.Sleep(10 * time.Millisecond) assert.NoError(t, tp.DischargeUserInteractive(userSecret, myCaveat("dis-cav"))) @@ -129,7 +138,7 @@ func TestServer(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - hdr, err = c.FetchDischargeTokens(ctx, hdr, nil) + hdr, err = c.FetchDischargeTokens(ctx, hdr) assert.NoError(t, err) cavs := checkFP(t, hdr) assert.Equal(t, []string{"fp-cav", "dis-cav"}, cavs) @@ -137,36 +146,65 @@ func TestServer(t *testing.T) { } var ( - fpLoc = "https://first-party" - fpKey = macaroon.NewSigningKey() - fpKID = []byte{1, 2, 3} + firstPartyLocation = "https://first-party" + fpKey = macaroon.NewSigningKey() + fpKID = []byte{1, 2, 3} ) -func genFP(tb testing.TB, tp *TP, caveats ...macaroon.Caveat) string { - tb.Helper() +func getFirstPartyMacaroonWithThirdPartyCaveat(thirdPartyLocation string, thirdPartyKey macaroon.EncryptionKey, otherCaveats ...macaroon.Caveat) (string, error) { + m, err := macaroon.New(fpKID, firstPartyLocation, fpKey) + if err != nil { + return "", err + } - m, err := macaroon.New(fpKID, fpLoc, fpKey) - assert.NoError(tb, err) + if err := m.Add(otherCaveats...); err != nil { + return "", err + } - assert.NoError(tb, m.Add(caveats...)) - assert.NoError(tb, m.Add3P(tp.Key, tp.Location, caveats...)) + if err := m.Add3P(thirdPartyKey, thirdPartyLocation); err != nil { + return "", err + } tok, err := m.Encode() - assert.NoError(tb, err) + if err != nil { + return "", err + } - return macaroon.ToAuthorizationHeader(tok) + return macaroon.ToAuthorizationHeader(tok), nil } -func checkFP(tb testing.TB, hdr string) []string { +func genFP(tb testing.TB, tp *TP, caveats ...macaroon.Caveat) string { tb.Helper() - fpb, dissb, err := macaroon.ParsePermissionAndDischargeTokens(hdr, fpLoc) + hdr, err := getFirstPartyMacaroonWithThirdPartyCaveat(tp.Location, tp.Key, caveats...) assert.NoError(tb, err) + return hdr +} + +func validateFirstPartyMacaroon(tokenHeader string) (*macaroon.CaveatSet, error) { + fpb, dissb, err := macaroon.ParsePermissionAndDischargeTokens(tokenHeader, firstPartyLocation) + if err != nil { + return nil, err + } + m, err := macaroon.Decode(fpb) - assert.NoError(tb, err) + if err != nil { + return nil, err + } cs, err := m.Verify(fpKey, dissb, nil) + if err != nil { + return nil, err + } + + return cs, nil +} + +func checkFP(tb testing.TB, hdr string) []string { + tb.Helper() + + cs, err := validateFirstPartyMacaroon(hdr) assert.NoError(tb, err) cavs := macaroon.GetCaveats[*myCaveat](cs) @@ -178,6 +216,26 @@ func checkFP(tb testing.TB, hdr string) []string { return ret } +func basicAuthClient(username, password string) *http.Client { + return &http.Client{ + Transport: &basicAuthTransport{ + t: http.DefaultTransport.(*http.Transport).Clone(), + username: username, + password: password, + }, + } +} + +type basicAuthTransport struct { + t http.RoundTripper + username, password string +} + +func (bat *basicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.SetBasicAuth(bat.username, bat.password) + return bat.t.RoundTrip(req) +} + type myCaveat string func init() { macaroon.RegisterCaveatType(new(myCaveat)) } From 3c753c9d88f7b9227b6f9a53e21d1327499f33c8 Mon Sep 17 00:00:00 2001 From: btoews Date: Thu, 19 Oct 2023 08:07:32 -0600 Subject: [PATCH 06/15] update README.md --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 20db686..a12217d 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ See the \`flyio\` package for more details. - [func \(m \*Macaroon\) BindToParentMacaroon\(parent \*Macaroon\) error](<#Macaroon.BindToParentMacaroon>) - [func \(m \*Macaroon\) Encode\(\) \(\[\]byte, error\)](<#Macaroon.Encode>) - [func \(m \*Macaroon\) Expiration\(\) time.Time](<#Macaroon.Expiration>) + - [func \(m \*Macaroon\) String\(\) \(string, error\)](<#Macaroon.String>) - [func \(m \*Macaroon\) ThirdPartyTicket\(location string, existingDischarges ...\[\]byte\) \(\[\]byte, error\)](<#Macaroon.ThirdPartyTicket>) - [func \(m \*Macaroon\) ThirdPartyTickets\(existingDischarges ...\[\]byte\) \(map\[string\]\[\]byte, error\)](<#Macaroon.ThirdPartyTickets>) - [func \(m \*Macaroon\) Verify\(k SigningKey, discharges \[\]\[\]byte, trusted3Ps map\[string\]EncryptionKey\) \(\*CaveatSet, error\)](<#Macaroon.Verify>) @@ -631,6 +632,15 @@ func (m *Macaroon) Expiration() time.Time Expiration calculates when this macaroon will expire + +### func \(\*Macaroon\) String + +```go +func (m *Macaroon) String() (string, error) +``` + +String encoded token with \`fm2\_\` prefix. + ### func \(\*Macaroon\) ThirdPartyTicket From 3d700bae02d312e879d17f37f58fa43fe76bddb7 Mon Sep 17 00:00:00 2001 From: btoews Date: Thu, 19 Oct 2023 09:58:55 -0600 Subject: [PATCH 07/15] pass context to user url callback --- tp/client.go | 8 ++++---- tp/tp_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tp/client.go b/tp/client.go index ac4d8e2..f9cf160 100644 --- a/tp/client.go +++ b/tp/client.go @@ -33,7 +33,7 @@ type Client struct { // browser if possible. Otherwise it should be displayed to the user and // they should be instructed to open it themselves. (Optional, but attempts // at user-interactive discharge flow will fail) - UserURLCallback func(url string) error + UserURLCallback func(ctx context.Context, url string) error // A function determining how long to wait before making the next request // when polling the third party to see if a discharge is ready. This is @@ -191,7 +191,7 @@ func (c *Client) doUserInteractive(ctx context.Context, ui *jsonUserInteractive) return "", errors.New("missing user-url callback") } - if err := c.openUserInteractiveURL(ui.UserURL); err != nil { + if err := c.openUserInteractiveURL(ctx, ui.UserURL); err != nil { return "", err } @@ -208,9 +208,9 @@ func (c *Client) nextBO(lastBO time.Duration) time.Duration { return 2 * lastBO } -func (c *Client) openUserInteractiveURL(url string) error { +func (c *Client) openUserInteractiveURL(ctx context.Context, url string) error { if c.UserURLCallback != nil { - return c.UserURLCallback(url) + return c.UserURLCallback(ctx, url) } return errors.New("client not configured for opening URLs") diff --git a/tp/tp_test.go b/tp/tp_test.go index 0c728d2..ec2942c 100644 --- a/tp/tp_test.go +++ b/tp/tp_test.go @@ -128,7 +128,7 @@ func TestTP(t *testing.T) { } return 10 * time.Second }, - UserURLCallback: func(url string) error { + UserURLCallback: func(_ context.Context, url string) error { time.Sleep(10 * time.Millisecond) assert.NoError(t, tp.DischargeUserInteractive(userSecret, myCaveat("dis-cav"))) return nil From c50bf4c2a2e4043e672efdf6d4433474f909492c Mon Sep 17 00:00:00 2001 From: btoews Date: Thu, 19 Oct 2023 10:11:20 -0600 Subject: [PATCH 08/15] helper for detecting if discharging is needed --- tp/client.go | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/tp/client.go b/tp/client.go index f9cf160..90bf19f 100644 --- a/tp/client.go +++ b/tp/client.go @@ -41,18 +41,17 @@ type Client struct { PollBackoffNext func(lastBO time.Duration) (nextBO time.Duration) } -func (c *Client) FetchDischargeTokens(ctx context.Context, tokenHeader string) (string, error) { - permTok, disToks, err := macaroon.ParsePermissionAndDischargeTokens(tokenHeader, c.FirstPartyLocation) +func (c *Client) NeedsDischarge(tokenHeader string) (bool, error) { + tickets, err := c.undischargedTickets(tokenHeader) if err != nil { - return "", err + return false, err } - perm, err := macaroon.Decode(permTok) - if err != nil { - return "", err - } + return len(tickets) != 0, nil +} - tickets, err := perm.ThirdPartyTickets(disToks...) +func (c *Client) FetchDischargeTokens(ctx context.Context, tokenHeader string) (string, error) { + tickets, err := c.undischargedTickets(tokenHeader) if err != nil { return "", err } @@ -86,6 +85,25 @@ func (c *Client) FetchDischargeTokens(ctx context.Context, tokenHeader string) ( return tokenHeader, combinedErr } +func (c *Client) undischargedTickets(tokenHeader string) (map[string][]byte, error) { + permTok, disToks, err := macaroon.ParsePermissionAndDischargeTokens(tokenHeader, c.FirstPartyLocation) + if err != nil { + return nil, err + } + + perm, err := macaroon.Decode(permTok) + if err != nil { + return nil, err + } + + tickets, err := perm.ThirdPartyTickets(disToks...) + if err != nil { + return nil, err + } + + return tickets, nil +} + func (c *Client) fetchDischargeToken(ctx context.Context, thirdPartyLocation string, ticket []byte) (string, error) { jresp, err := c.doInitRequest(ctx, thirdPartyLocation, ticket) From 600c9b7e25846b1cba1cefc26a0e4444fa5412f4 Mon Sep 17 00:00:00 2001 From: btoews Date: Thu, 19 Oct 2023 10:57:37 -0600 Subject: [PATCH 09/15] allow multiple permission tokens --- tp/client.go | 55 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/tp/client.go b/tp/client.go index 90bf19f..ddb682c 100644 --- a/tp/client.go +++ b/tp/client.go @@ -62,22 +62,24 @@ func (c *Client) FetchDischargeTokens(ctx context.Context, tokenHeader string) ( combinedErr error ) - for tpLoc, ticket := range tickets { - wg.Add(1) - go func(tpLoc string, ticket []byte) { - defer wg.Done() - - dis, err := c.fetchDischargeToken(ctx, tpLoc, ticket) - - m.Lock() - defer m.Unlock() - - if err != nil { - combinedErr = merr.Append(combinedErr, err) - } else { - tokenHeader = tokenHeader + "," + dis - } - }(tpLoc, ticket) + for tpLoc, locTickets := range tickets { + for _, ticket := range locTickets { + wg.Add(1) + go func(tpLoc string, ticket []byte) { + defer wg.Done() + + dis, err := c.fetchDischargeToken(ctx, tpLoc, ticket) + + m.Lock() + defer m.Unlock() + + if err != nil { + combinedErr = merr.Append(combinedErr, err) + } else { + tokenHeader = tokenHeader + "," + dis + } + }(tpLoc, ticket) + } } wg.Wait() @@ -85,23 +87,30 @@ func (c *Client) FetchDischargeTokens(ctx context.Context, tokenHeader string) ( return tokenHeader, combinedErr } -func (c *Client) undischargedTickets(tokenHeader string) (map[string][]byte, error) { - permTok, disToks, err := macaroon.ParsePermissionAndDischargeTokens(tokenHeader, c.FirstPartyLocation) +func (c *Client) undischargedTickets(tokenHeader string) (map[string][][]byte, error) { + toks, err := macaroon.Parse(tokenHeader) if err != nil { return nil, err } - perm, err := macaroon.Decode(permTok) + perms, _, _, disToks, err := macaroon.FindPermissionAndDischargeTokens(toks, c.FirstPartyLocation) if err != nil { return nil, err } - tickets, err := perm.ThirdPartyTickets(disToks...) - if err != nil { - return nil, err + ret := make(map[string][][]byte) + for _, perm := range perms { + tickets, err := perm.ThirdPartyTickets(disToks...) + if err != nil { + return nil, err + } + + for loc, ticket := range tickets { + ret[loc] = append(ret[loc], ticket) + } } - return tickets, nil + return ret, nil } func (c *Client) fetchDischargeToken(ctx context.Context, thirdPartyLocation string, ticket []byte) (string, error) { From b5d90319fa922b1125ec2c89e576f34394a0a5bc Mon Sep 17 00:00:00 2001 From: btoews Date: Thu, 19 Oct 2023 13:43:40 -0600 Subject: [PATCH 10/15] fix nonce-only parsing --- macaroon.go | 20 ++++++++++++++------ macaroon_test.go | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/macaroon.go b/macaroon.go index 6adb9ba..9dde49a 100644 --- a/macaroon.go +++ b/macaroon.go @@ -185,12 +185,20 @@ func Decode(buf []byte) (*Macaroon, error) { // DecodeNonce parses just the [Nonce] from an encoded [Macaroon]. // You'd want to do this, for instance, to look metadata up by the // keyid of the [Macaroon], which is encoded in the [Nonce]. -func DecodeNonce(buf []byte) (Nonce, error) { - var ( - nonceOnly = struct{ Nonce Nonce }{} - err = msgpack.Unmarshal(buf, &nonceOnly) - ) - return nonceOnly.Nonce, err +func DecodeNonce(buf []byte) (nonce Nonce, err error) { + dec := msgpack.NewDecoder(bytes.NewReader(buf)) + + var n int + switch n, err = dec.DecodeArrayLen(); { + case err != nil: + return + case n == 0: + err = errors.New("bad nonce") + return + } + + err = dec.Decode(&nonce) + return } // Add adds a caveat to a Macaroon, adjusting the tail signature in diff --git a/macaroon_test.go b/macaroon_test.go index 1e0dd16..da845ea 100644 --- a/macaroon_test.go +++ b/macaroon_test.go @@ -536,6 +536,10 @@ func TestSimple3P(t *testing.T) { action: ActionRead, }) assert.NoError(t, err) + + tps, err = decoded.ThirdPartyTickets(aBuf) + assert.NoError(t, err) + assert.Equal(t, 0, len(tps)) }) } } @@ -631,6 +635,19 @@ func TestDuplicateCaveats(t *testing.T) { assert.Equal(t, 5, len(m.UnsafeCaveats.Caveats)) } +func TestDecodeNonce(t *testing.T) { + m, err := New(rbuf(10), "x", NewSigningKey()) + assert.NoError(t, err) + + mb, err := m.Encode() + assert.NoError(t, err) + + n, err := DecodeNonce(mb) + assert.NoError(t, err) + + assert.Equal(t, m.Nonce, n) +} + func dischargeMacaroon(ka EncryptionKey, location string, encodedMacaroon []byte) (bool, []Caveat, *Macaroon, error) { ticket, err := ThirdPartyTicket(encodedMacaroon, location) if err != nil || len(ticket) == 0 { From e519842436044170137dd5b95a102cfc148a50bf Mon Sep 17 00:00:00 2001 From: btoews Date: Thu, 19 Oct 2023 13:50:36 -0600 Subject: [PATCH 11/15] fix discharge deduping --- macaroon.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macaroon.go b/macaroon.go index 9dde49a..f88e349 100644 --- a/macaroon.go +++ b/macaroon.go @@ -549,7 +549,7 @@ func (m *Macaroon) ThirdPartyTickets(existingDischarges ...[]byte) (map[string][ return nil, fmt.Errorf("extract third party caveats: duplicate locations: %s", cav.Location) } - if _, discharged := dischargeTickets[hex.EncodeToString(m.Nonce.KID)]; !discharged { + if _, discharged := dischargeTickets[hex.EncodeToString(cav.Ticket)]; !discharged { ret[cav.Location] = cav.Ticket } } From ebce8b081e9c796db6871d67d3adf9152216cbe0 Mon Sep 17 00:00:00 2001 From: btoews Date: Thu, 19 Oct 2023 14:08:39 -0600 Subject: [PATCH 12/15] fix readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a12217d..bef7e84 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ See the \`flyio\` package for more details. - [func \(m \*Macaroon\) ThirdPartyTickets\(existingDischarges ...\[\]byte\) \(map\[string\]\[\]byte, error\)](<#Macaroon.ThirdPartyTickets>) - [func \(m \*Macaroon\) Verify\(k SigningKey, discharges \[\]\[\]byte, trusted3Ps map\[string\]EncryptionKey\) \(\*CaveatSet, error\)](<#Macaroon.Verify>) - [type Nonce](<#Nonce>) - - [func DecodeNonce\(buf \[\]byte\) \(Nonce, error\)](<#DecodeNonce>) + - [func DecodeNonce\(buf \[\]byte\) \(nonce Nonce, err error\)](<#DecodeNonce>) - [func \(n \*Nonce\) DecodeMsgpack\(d \*msgpack.Decoder\) error](<#Nonce.DecodeMsgpack>) - [func \(n \*Nonce\) EncodeMsgpack\(e \*msgpack.Encoder\) error](<#Nonce.EncodeMsgpack>) - [func \(n Nonce\) MustEncode\(\) \[\]byte](<#Nonce.MustEncode>) @@ -693,7 +693,7 @@ type Nonce struct { ### func DecodeNonce ```go -func DecodeNonce(buf []byte) (Nonce, error) +func DecodeNonce(buf []byte) (nonce Nonce, err error) ``` DecodeNonce parses just the [Nonce](<#Nonce>) from an encoded [Macaroon](<#Macaroon>). You'd want to do this, for instance, to look metadata up by the keyid of the [Macaroon](<#Macaroon>), which is encoded in the [Nonce](<#Nonce>). From 5bb766b5f04da1bbcf1cee78dd98bf0d0d982b67 Mon Sep 17 00:00:00 2001 From: btoews Date: Fri, 20 Oct 2023 13:47:33 -0600 Subject: [PATCH 13/15] caveat type identifiers for some sso stuff --- caveat.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/caveat.go b/caveat.go index 50d4691..f8a16ef 100644 --- a/caveat.go +++ b/caveat.go @@ -32,6 +32,9 @@ const ( CavFlyioClusters _ // fly.io reserved _ // fly.io reserved + CavFlyioRequireGoogleHD + CavFlyioRequireGitHubOrg + CavFlyioDischargeExpiryLTE // Globally-recognized user-registerable caveat types may be requested via // pull requests to this repository. Add a meaningful name of the caveat From 72fa194ac3450eb05faa427027e22a8bb7e996fe Mon Sep 17 00:00:00 2001 From: btoews Date: Fri, 20 Oct 2023 13:47:47 -0600 Subject: [PATCH 14/15] don't do the sync.Once stuff in memorystore --- tp/store.go | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tp/store.go b/tp/store.go index b29d60e..333e966 100644 --- a/tp/store.go +++ b/tp/store.go @@ -5,7 +5,6 @@ import ( "errors" "net/http" "strings" - "sync" lru "github.com/hashicorp/golang-lru/v2" "golang.org/x/crypto/blake2b" @@ -34,10 +33,8 @@ type UserSecretMunger interface { type MemoryStore struct { UserSecretMunger - Cache *lru.Cache[string, *StoreData] - - _secret []byte - _initSecretOnce sync.Once + Cache *lru.Cache[string, *StoreData] + secret []byte } func NewMemoryStore(m UserSecretMunger, size int) (*MemoryStore, error) { @@ -49,6 +46,7 @@ func NewMemoryStore(m UserSecretMunger, size int) (*MemoryStore, error) { return &MemoryStore{ Cache: cache, UserSecretMunger: PrefixMunger("/user/"), + secret: randBytes(32), }, nil } @@ -87,7 +85,7 @@ func (s *MemoryStore) GetByUserSecret(userSecret string) (*StoreData, error) { } func (s *MemoryStore) ticketSecrets(t []byte) (string, string) { - h, err := blake2b.New(32, s.secret()) + h, err := blake2b.New(32, s.secret) if err != nil { panic(err) } @@ -99,11 +97,6 @@ func (s *MemoryStore) ticketSecrets(t []byte) (string, string) { return hex.EncodeToString(d[:16]), hex.EncodeToString(d[16:]) } -func (s *MemoryStore) secret() []byte { - s._initSecretOnce.Do(func() { s._secret = randBytes(32) }) - return s._secret -} - func digest[T string | []byte](d T) string { digest := blake2b.Sum256([]byte(d)) return hex.EncodeToString(digest[:]) From 093f7391f8b146de1e19a7294bb91ea741ad7331 Mon Sep 17 00:00:00 2001 From: btoews Date: Fri, 27 Oct 2023 12:21:16 -0600 Subject: [PATCH 15/15] typos --- tp/store.go | 2 +- tp/store_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tp/store.go b/tp/store.go index 333e966..98c94df 100644 --- a/tp/store.go +++ b/tp/store.go @@ -45,7 +45,7 @@ func NewMemoryStore(m UserSecretMunger, size int) (*MemoryStore, error) { return &MemoryStore{ Cache: cache, - UserSecretMunger: PrefixMunger("/user/"), + UserSecretMunger: m, secret: randBytes(32), }, nil } diff --git a/tp/store_test.go b/tp/store_test.go index 4c4680c..6a4534d 100644 --- a/tp/store_test.go +++ b/tp/store_test.go @@ -10,7 +10,7 @@ func TestMemoryStoreSecrets(t *testing.T) { ms, err := NewMemoryStore(PrefixMunger("/user/"), 100) assert.NoError(t, err) - assert.Equal(t, 32, len(ms.secret())) + assert.Equal(t, 32, len(ms.secret)) x, y := ms.ticketSecrets([]byte("hi")) assert.Equal(t, 32, len(x))