Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

3p discharge protocol #9

Merged
merged 16 commits into from
Oct 27, 2023
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
3 changes: 3 additions & 0 deletions caveat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion caveat_set_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
)

Expand Down
6 changes: 3 additions & 3 deletions caveats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
Expand Down
38 changes: 19 additions & 19 deletions cid.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
111 changes: 64 additions & 47 deletions macaroon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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())
Expand All @@ -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})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{}{}
}
}

Expand All @@ -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
Expand All @@ -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
}
Loading