diff --git a/README.md b/README.md index c0bb81a..f7b0dba 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,4 @@ This is the extracted Macaroon token code we use for authorization inside of Fly.io. Because [flyctl](https://github.com/superfly/flyctl), our CLI, is open source, it can't fully exploit our tokens unless this library is open source as well. So it is. -We don't think you should use any of this code; it's shrink-wrapped around some peculiar details of our production network, and the data model is Fly-specific. But if it's an interesting to read, that's great too. \ No newline at end of file +We don't think you should use any of this code; it's shrink-wrapped around some peculiar details of our production network, and the data model is Fly-specific. But if it's an interesting to read, that's great too. 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 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..af20a9f 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) { + tRaw, 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) + 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(cid, location, tcid.RN, issueProof) + dm, err := newMacaroon(ticket, location, tWire.DischargeKey, issueProof) if err != nil { return nil, nil, err } - return tcid.Caveats.Caveats, dm, nil + return tWire.Caveats.Caveats, dm, nil } diff --git a/go.mod b/go.mod index c344cdd..54a1453 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ 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 golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 diff --git a/go.sum b/go.sum index bcf3f7f..f2ca0c2 100644 --- a/go.sum +++ b/go.sum @@ -4,14 +4,20 @@ 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/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= 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 +28,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..f88e349 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. @@ -186,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 @@ -219,7 +226,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 +319,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 +338,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 +389,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 +497,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 +549,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(cav.Ticket)]; !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 @@ -577,3 +584,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/macaroon_test.go b/macaroon_test.go index 29aeb09..da845ea 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() @@ -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,12 +635,25 @@ 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) { - 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 } diff --git a/tp/README.md b/tp/README.md new file mode 100644 index 0000000..4769e7f --- /dev/null +++ b/tp/README.md @@ -0,0 +1,215 @@ +# 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": "/url/or/path/to/poll", + "user_interactive": { + "user_url": "", + "poll_url": "" + } +} +``` + +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 diff --git a/tp/client.go b/tp/client.go new file mode 100644 index 0000000..ddb682c --- /dev/null +++ b/tp/client.go @@ -0,0 +1,267 @@ +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 ClientOption func(*Client) + +type Client struct { + // Location identifier for the party that issued the first party macaroon. + FirstPartyLocation string + + // 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(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 + // called the first time with a zero duration. (Optional) + PollBackoffNext func(lastBO time.Duration) (nextBO time.Duration) +} + +func (c *Client) NeedsDischarge(tokenHeader string) (bool, error) { + tickets, err := c.undischargedTickets(tokenHeader) + if err != nil { + return false, err + } + + return len(tickets) != 0, nil +} + +func (c *Client) FetchDischargeTokens(ctx context.Context, tokenHeader string) (string, error) { + tickets, err := c.undischargedTickets(tokenHeader) + if err != nil { + return "", err + } + + var ( + wg sync.WaitGroup + m sync.Mutex + combinedErr error + ) + + 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() + + return tokenHeader, combinedErr +} + +func (c *Client) undischargedTickets(tokenHeader string) (map[string][][]byte, error) { + toks, err := macaroon.Parse(tokenHeader) + if err != nil { + return nil, err + } + + perms, _, _, disToks, err := macaroon.FindPermissionAndDischargeTokens(toks, c.FirstPartyLocation) + 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 ret, nil +} + +func (c *Client) fetchDischargeToken(ctx context.Context, thirdPartyLocation string, ticket []byte) (string, error) { + jresp, err := c.doInitRequest(ctx, thirdPartyLocation, ticket) + + switch { + case err != nil: + return "", err + case jresp.Discharge != "": + return jresp.Discharge, nil + case jresp.PollURL != "": + return c.doPoll(ctx, jresp.PollURL) + case jresp.UserInteractive != nil: + return c.doUserInteractive(ctx, jresp.UserInteractive) + default: + return "", errors.New("bad discharge response") + } +} + +func (c *Client) doInitRequest(ctx context.Context, thirdPartyLocation string, ticket []byte) (*jsonResponse, error) { + jreq := &jsonInitRequest{ + Ticket: ticket, + } + + breq, err := json.Marshal(jreq) + if err != nil { + return nil, err + } + + 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 := c.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 (c *Client) doPoll(ctx context.Context, pollURL string) (string, error) { + if pollURL == "" { + return "", errors.New("bad discharge response") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil) + if err != nil { + return "", err + } + + var ( + bo time.Duration + jresp jsonResponse + ) + +pollLoop: + for { + 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): + continue pollLoop + case <-ctx.Done(): + return "", 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 (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 := c.openUserInteractiveURL(ctx, ui.UserURL); err != nil { + return "", err + } + + 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(ctx context.Context, url string) error { + if c.UserURLCallback != nil { + return c.UserURLCallback(ctx, 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 initURL(location string) string { + if strings.HasSuffix(location, "/") { + return location + InitPath[1:] + } + return location + InitPath +} + +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/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/server.go b/tp/server.go new file mode 100644 index 0000000..6692539 --- /dev/null +++ b/tp/server.go @@ -0,0 +1,410 @@ +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 { + 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.ResponseBody == nil || sd.ResponseStatus == 0 { + 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 + } + + 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 + } + + log.Info() +} + +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, &jsonResponse{ + 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, &jsonResponse{ + Discharge: tok, + }) +} + +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 "" + } + + _, 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, &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) 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 "" + } + + 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, &jsonResponse{ + UserInteractive: &jsonUserInteractive{ + PollURL: tp.url("/poll/" + pollSecret), + UserURL: store.UserSecretToURL(userSecret), + }, + }) + + return userSecret +} + +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 + } + + 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) { + 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 { + return nil, err + } + + fd := &flowData{ + ticket: ticket, + caveats: caveats, + discharge: discharge, + log: log.WithField("tid", digest(ticket)), + } + + return fd, nil +} + +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 r != nil { + 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/store.go b/tp/store.go new file mode 100644 index 0000000..98c94df --- /dev/null +++ b/tp/store.go @@ -0,0 +1,119 @@ +package tp + +import ( + "encoding/hex" + "errors" + "net/http" + "strings" + + lru "github.com/hashicorp/golang-lru/v2" + "golang.org/x/crypto/blake2b" +) + +type StoreData struct { + Ticket []byte + ResponseStatus int + ResponseBody []byte +} + +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 +} + +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: m, + secret: randBytes(32), + }, 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 digest[T string | []byte](d T) 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..6a4534d --- /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.ResponseBody = []byte{1, 2, 3} + 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, []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, []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 new file mode 100644 index 0000000..024e195 --- /dev/null +++ b/tp/tp.go @@ -0,0 +1,22 @@ +package tp + +const ( + InitPath = "/.well-known/macfly/3p" + PollPathPrefix = "/.well-known/macfly/3p/poll/" +) + +type jsonInitRequest struct { + Ticket []byte `json:"ticket,omitempty"` +} + +type jsonResponse 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"` +} diff --git a/tp/tp_test.go b/tp/tp_test.go new file mode 100644 index 0000000..ec2942c --- /dev/null +++ b/tp/tp_test.go @@ -0,0 +1,245 @@ +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 TestTP(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()) + } + })) + t.Cleanup(s.Close) + + 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: 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) + }) + + 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: 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) + 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) + 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: firstPartyLocation, + PollBackoffNext: func(last time.Duration) time.Duration { + if last == 0 { + return 10 * time.Millisecond + } + return 10 * time.Second + }, + UserURLCallback: func(_ context.Context, 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) + assert.NoError(t, err) + cavs := checkFP(t, hdr) + assert.Equal(t, []string{"fp-cav", "dis-cav"}, cavs) + }) +} + +var ( + firstPartyLocation = "https://first-party" + fpKey = macaroon.NewSigningKey() + fpKID = []byte{1, 2, 3} +) + +func getFirstPartyMacaroonWithThirdPartyCaveat(thirdPartyLocation string, thirdPartyKey macaroon.EncryptionKey, otherCaveats ...macaroon.Caveat) (string, error) { + m, err := macaroon.New(fpKID, firstPartyLocation, fpKey) + if err != nil { + return "", err + } + + if err := m.Add(otherCaveats...); err != nil { + return "", err + } + + if err := m.Add3P(thirdPartyKey, thirdPartyLocation); err != nil { + return "", err + } + + tok, err := m.Encode() + if err != nil { + return "", err + } + + return macaroon.ToAuthorizationHeader(tok), nil +} + +func genFP(tb testing.TB, tp *TP, caveats ...macaroon.Caveat) string { + tb.Helper() + + 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) + 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) + ret := make([]string, len(cavs)) + for i := range cavs { + ret[i] = string(*cavs[i]) + } + + 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)) } + +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 }