mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-03-13 18:00:34 -05:00
feat: proof keys verification for the collaboration service
This commit is contained in:
@@ -9,4 +9,11 @@ type App struct {
|
||||
|
||||
Addr string `yaml:"addr" env:"COLLABORATION_APP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080." introductionVersion:"6.0.0"`
|
||||
Insecure bool `yaml:"insecure" env:"COLLABORATION_APP_INSECURE" desc:"Skip TLS certificate verification when connecting to the WOPI app" introductionVersion:"6.0.0"`
|
||||
|
||||
ProofKeys ProofKeys `yaml:"proofkeys"`
|
||||
}
|
||||
|
||||
type ProofKeys struct {
|
||||
Disable bool `yaml:"disable" env:"COLLABORATION_APP_PROOF_DISABLE" desc:"Disable the proof keys verification" introductionVersion:"6.0.0"`
|
||||
Duration string `yaml:"duration" env:"COLLABORATION_APP_PROOF_DURATION" desc:"Duration for the proof keys to be cached in memory, using time.ParseDuration format. If the duration can't be parsed, we'll use the default 12h as duration" introductionVersion:"6.0.0"`
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ func DefaultConfig() *config.Config {
|
||||
LockName: "com.github.owncloud.collaboration",
|
||||
Addr: "https://127.0.0.1:9980",
|
||||
Insecure: false,
|
||||
ProofKeys: config.ProofKeys{
|
||||
// they'll be enabled by default
|
||||
Duration: "12h",
|
||||
},
|
||||
},
|
||||
GRPC: config.GRPC{
|
||||
Addr: "127.0.0.1:9301",
|
||||
|
||||
55
services/collaboration/pkg/middleware/proofkeys.go
Normal file
55
services/collaboration/pkg/middleware/proofkeys.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/collaboration/pkg/proofkeys"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func ProofKeysMiddleware(cfg *config.Config, next http.Handler) http.Handler {
|
||||
wopiDiscovery := cfg.App.Addr + "/hosting/discovery"
|
||||
insecure := cfg.App.Insecure
|
||||
cacheDuration, err := time.ParseDuration(cfg.App.ProofKeys.Duration)
|
||||
if err != nil {
|
||||
cacheDuration = 12 * time.Hour
|
||||
}
|
||||
|
||||
pkHandler := proofkeys.NewVerifyHandler(wopiDiscovery, insecure, cacheDuration)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
logger := zerolog.Ctx(r.Context())
|
||||
|
||||
// the url we need is the one being requested, but we need the
|
||||
// scheme and host, so we'll get those from the configured WOPISrc
|
||||
wopiSrcURL, _ := url.Parse(cfg.Wopi.WopiSrc)
|
||||
currentURL, _ := url.Parse(r.URL.String())
|
||||
currentURL.Scheme = wopiSrcURL.Scheme
|
||||
currentURL.Host = wopiSrcURL.Host
|
||||
|
||||
accessToken := r.URL.Query().Get("access_token")
|
||||
stamp := r.Header.Get("X-WOPI-TimeStamp")
|
||||
|
||||
err := pkHandler.Verify(
|
||||
accessToken,
|
||||
currentURL.String(),
|
||||
stamp,
|
||||
r.Header.Get("X-WOPI-Proof"),
|
||||
r.Header.Get("X-WOPI-ProofOld"),
|
||||
proofkeys.VerifyWithLogger(logger),
|
||||
)
|
||||
// Need to check that the timestamp was sent within the last 20 minutes
|
||||
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("ProofKeys verification failed")
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Msg("ProofKeys verified")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -90,6 +90,9 @@ func WopiContextAuthMiddleware(jwtSecret string, next http.Handler) http.Handler
|
||||
logger := zerolog.Ctx(ctx)
|
||||
ctx = logger.With().
|
||||
Str("WopiOverride", r.Header.Get("X-WOPI-Override")).
|
||||
Str("WopiProof", r.Header.Get("X-WOPI-Proof")).
|
||||
Str("WopiProofOld", r.Header.Get("X-WOPI-ProofOld")).
|
||||
Str("WopiStamp", r.Header.Get("X-WOPI-TimeStamp")).
|
||||
Str("FileReference", claims.WopiContext.FileReference.String()).
|
||||
Str("ViewMode", claims.WopiContext.ViewMode.String()).
|
||||
Str("Requester", claims.WopiContext.User.GetId().String()).
|
||||
|
||||
@@ -11,31 +11,46 @@ import (
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type PubKeys struct {
|
||||
Key *rsa.PublicKey
|
||||
OldKey *rsa.PublicKey
|
||||
Key *rsa.PublicKey
|
||||
OldKey *rsa.PublicKey
|
||||
ExpireTime time.Time
|
||||
}
|
||||
|
||||
type Verifier interface {
|
||||
Verify(accessToken, url, timestamp, sig64, oldSig64 string) error
|
||||
Verify(accessToken, url, timestamp, sig64, oldSig64 string, opts ...VerifyOption) error
|
||||
}
|
||||
|
||||
type VerifyHandler struct {
|
||||
discoveryURL string
|
||||
insecure bool
|
||||
logger log.Logger
|
||||
cachedKeys *PubKeys
|
||||
cachedDur time.Duration
|
||||
}
|
||||
|
||||
func NewVerifyHandler(discoveryURL string, insecure bool, logger log.Logger) Verifier {
|
||||
// NewVerifyHandler will return a new Verifier with the provided parameters
|
||||
// The discoveryURL must point to the https://office.wopi/hosting/discovery
|
||||
// address, which contains the xml with the proof keys (and more information)
|
||||
// The insecure parameter can be used to disable certificate verification when
|
||||
// conecting to the provided discoveryURL
|
||||
// CachedDur is the duration the keys will be cached in memory. The cached keys
|
||||
// will be used for the duration provided, after that new keys will be fetched
|
||||
// from the discoveryURL.
|
||||
//
|
||||
// For WOPI apps whose proof keys rotate after a while, you must ensure that
|
||||
// the provided duration is shorter than the rotation time. This should
|
||||
// guarantee that we can't fail to verify a request due to obsolete keys.
|
||||
func NewVerifyHandler(discoveryURL string, insecure bool, cachedDur time.Duration) Verifier {
|
||||
return &VerifyHandler{
|
||||
discoveryURL: discoveryURL,
|
||||
insecure: insecure,
|
||||
logger: logger,
|
||||
cachedDur: cachedDur,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +62,7 @@ func NewVerifyHandler(discoveryURL string, insecure bool, logger log.Logger) Ver
|
||||
// "http://wopiserver:8888/wopi/file/abcdef?access_token=zzxxyy"
|
||||
// * timestamp: The timestamp provided by the WOPI app in the "X-WOPI-TimeStamp" header, as string
|
||||
// * sig64: The base64-encoded signature, which should come directly from the "X-WOPI-Proof" header
|
||||
// * olSig64: The base64-encoded previous signature, coming from the "X-WOPI-ProofOld" header
|
||||
// * oldSig64: The base64-encoded previous signature, coming from the "X-WOPI-ProofOld" header
|
||||
//
|
||||
// The public keys will be obtained from the /hosting/discovery path of the target WOPI app.
|
||||
// Note that the method will perform the following checks in that order:
|
||||
@@ -57,7 +72,14 @@ func NewVerifyHandler(discoveryURL string, insecure bool, logger log.Logger) Ver
|
||||
// If all of those checks are wrong, the method will fail, and the request should be rejected.
|
||||
//
|
||||
// The method will return an error if something fails, or nil if everything is ok
|
||||
func (vh *VerifyHandler) Verify(accessToken, url, timestamp, sig64, oldSig64 string) error {
|
||||
func (vh *VerifyHandler) Verify(accessToken, url, timestamp, sig64, oldSig64 string, opts ...VerifyOption) error {
|
||||
verifyOptions := newOptions(opts...)
|
||||
|
||||
// check timestamp
|
||||
if err := vh.checkTimestamp(timestamp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// need to decode the signatures
|
||||
signature, err := base64.StdEncoding.DecodeString(sig64)
|
||||
if err != nil {
|
||||
@@ -73,10 +95,14 @@ func (vh *VerifyHandler) Verify(accessToken, url, timestamp, sig64, oldSig64 str
|
||||
}
|
||||
}
|
||||
|
||||
// fetch the public keys
|
||||
pubkeys, err := vh.fetchPublicKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
pubkeys := vh.cachedKeys
|
||||
if pubkeys == nil || pubkeys.ExpireTime.Before(time.Now()) {
|
||||
// fetch the public keys
|
||||
newpubkeys, err := vh.fetchPublicKeys(verifyOptions.Logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pubkeys = newpubkeys
|
||||
}
|
||||
|
||||
// build and hash the expected proof
|
||||
@@ -96,6 +122,46 @@ func (vh *VerifyHandler) Verify(accessToken, url, timestamp, sig64, oldSig64 str
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTimestamp will check if the provided timestamp is valid.
|
||||
// The timestamp is valid if it isn't older than 20 minutes (info from
|
||||
// MS WOPI docs).
|
||||
//
|
||||
// Note: the timestamp is based on C# DateTime.UtcNow.Ticks
|
||||
// "One tick equals 100 nanoseconds. The value of this property represents
|
||||
// the number of ticks that have elapsed since 12:00:00 midnight, January 1, 0001."
|
||||
// It is NOT a unix timestamp (current unix timestamp ~1718123417 secs ;
|
||||
// expected timestamp ~638537195321890000 100-nanosecs)
|
||||
func (vh *VerifyHandler) checkTimestamp(timestamp string) error {
|
||||
var stamp, unixBaseStamp, unixStamp, unixStampSec, unixStampNanoSec big.Int
|
||||
|
||||
// set the stamp
|
||||
_, ok := stamp.SetString(timestamp, 10)
|
||||
if !ok {
|
||||
return errors.New("Invalid timestamp")
|
||||
}
|
||||
|
||||
// 62135596800 seconds from "January 1, 1 AD" to "January 1, 1970 12:00:00 AM"
|
||||
// need to convert those secs into 100-nanosecs in order to compare the stamp
|
||||
unixBaseStamp.Mul(big.NewInt(62135596800), big.NewInt(1000*1000*10))
|
||||
|
||||
// stamp - unixBaseStamp gives us the unix-based timestamp we can use
|
||||
unixStamp.Sub(&stamp, &unixBaseStamp)
|
||||
|
||||
// divide between 1000*1000*10 to get the seconds and 100-nanoseconds
|
||||
unixStampSec.DivMod(&unixStamp, big.NewInt(1000*1000*10), &unixStampNanoSec)
|
||||
|
||||
// time package requires nanoseconds (var will be overwritten with the result)
|
||||
unixStampNanoSec.Mul(&unixStampNanoSec, big.NewInt(100))
|
||||
|
||||
// both seconds and nanoseconds should be within int64 range
|
||||
convertedUnixTimestamp := time.Unix(unixStampSec.Int64(), unixStampNanoSec.Int64())
|
||||
|
||||
if time.Now().After(convertedUnixTimestamp.Add(20 * time.Minute)) {
|
||||
return errors.New("Timestamp expired")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateProof will generated a expected proof to be verified later.
|
||||
// The method will return a slice of bytes with the proof (consider it binary
|
||||
// data).
|
||||
@@ -131,7 +197,7 @@ func (vh *VerifyHandler) generateProof(accessToken, url, timestamp string) []byt
|
||||
// and exponent found.
|
||||
// The PubKeys returned might be either nil (with the non-nil error), or might
|
||||
// contain only a PubKeys.Key field (the PubKeys.OldKey might be nil)
|
||||
func (vh *VerifyHandler) fetchPublicKeys() (*PubKeys, error) {
|
||||
func (vh *VerifyHandler) fetchPublicKeys(logger *zerolog.Logger) (*PubKeys, error) {
|
||||
httpClient := http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
@@ -142,7 +208,7 @@ func (vh *VerifyHandler) fetchPublicKeys() (*PubKeys, error) {
|
||||
|
||||
httpResp, err := httpClient.Get(vh.discoveryURL)
|
||||
if err != nil {
|
||||
vh.logger.Error().
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("WopiAppUrl", vh.discoveryURL).
|
||||
Msg("WopiDiscovery: failed to access wopi app url")
|
||||
@@ -152,7 +218,7 @@ func (vh *VerifyHandler) fetchPublicKeys() (*PubKeys, error) {
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
vh.logger.Error().
|
||||
logger.Error().
|
||||
Str("WopiAppUrl", vh.discoveryURL).
|
||||
Int("HttpCode", httpResp.StatusCode).
|
||||
Msg("WopiDiscovery: wopi app url failed with unexpected code")
|
||||
@@ -184,7 +250,8 @@ func (vh *VerifyHandler) fetchPublicKeys() (*PubKeys, error) {
|
||||
}
|
||||
|
||||
keys := &PubKeys{
|
||||
Key: vh.keyFromBase64(mod64, exp64),
|
||||
Key: vh.keyFromBase64(mod64, exp64),
|
||||
ExpireTime: time.Now().Add(vh.cachedDur),
|
||||
}
|
||||
|
||||
if oldMod64 != "" && oldExp64 != "" {
|
||||
|
||||
35
services/collaboration/pkg/proofkeys/option.go
Normal file
35
services/collaboration/pkg/proofkeys/option.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package proofkeys
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// VerifyOption defines a single option function.
|
||||
type VerifyOption func(o *VerifyOptions)
|
||||
|
||||
// VerifyOptions defines the available options for the Verify function.
|
||||
type VerifyOptions struct {
|
||||
Logger *zerolog.Logger
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...VerifyOption) VerifyOptions {
|
||||
defaultLog := zerolog.Nop()
|
||||
opt := VerifyOptions{
|
||||
//Logger: log.NopLogger(), // use a NopLogger by default
|
||||
Logger: &defaultLog, // use a NopLogger by default
|
||||
}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// VerifyWithLogger provides a function to set the Logger option.
|
||||
func VerifyWithLogger(val *zerolog.Logger) VerifyOption {
|
||||
return func(o *VerifyOptions) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,13 @@ func prepareRoutes(r *chi.Mux, options Options) {
|
||||
return colabmiddleware.WopiContextAuthMiddleware(options.Config.Wopi.Secret, h)
|
||||
})
|
||||
|
||||
// check whether we should check for proof keys
|
||||
if !options.Config.App.ProofKeys.Disable {
|
||||
r.Use(func(h stdhttp.Handler) stdhttp.Handler {
|
||||
return colabmiddleware.ProofKeysMiddleware(options.Config, h)
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
adapter.CheckFileInfo(w, r)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user