Skip to content

Commit

Permalink
Merge pull request #9366 from owncloud/collaboration_proofkeys
Browse files Browse the repository at this point in the history
Collaboration proofkeys
  • Loading branch information
jvillafanez committed Jul 25, 2024
2 parents b63cff9 + d2f6679 commit a7d8124
Show file tree
Hide file tree
Showing 9 changed files with 401 additions and 1 deletion.
4 changes: 3 additions & 1 deletion .drone.star
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,8 @@ def wopiValidatorTests(ctx, storage, wopiServerType, accounts_hash_difficulty =
"COLLABORATION_LOG_LEVEL": "debug",
"COLLABORATION_HTTP_ADDR": "0.0.0.0:9300",
"COLLABORATION_GRPC_ADDR": "0.0.0.0:9301",
# no proof keys available in the FakeOffice
"COLLABORATION_APP_PROOF_DISABLE": "true",
"COLLABORATION_APP_NAME": "FakeOffice",
"COLLABORATION_APP_ADDR": "http://fakeoffice:8080",
"COLLABORATION_APP_INSECURE": "true",
Expand All @@ -1025,7 +1027,7 @@ def wopiValidatorTests(ctx, storage, wopiServerType, accounts_hash_difficulty =
"export WOPI_SRC=$(cat wopisrc)",
"echo $WOPI_SRC",
"cd /app",
"/app/Microsoft.Office.WopiValidator -s -t $WOPI_TOKEN -w $WOPI_SRC -l $WOPI_TTL --testgroup %s" % testgroup,
"/app/Microsoft.Office.WopiValidator -t $WOPI_TOKEN -w $WOPI_SRC -l $WOPI_TTL --testgroup %s" % testgroup,
],
})

Expand Down
8 changes: 8 additions & 0 deletions changelog/unreleased/collaboration-proofkeys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Enhancement: Add support for proof keys for the collaboration service

Proof keys support will be enabled by default in order to ensure that all
the requests come from a trusted source.
Since proof keys must be set in the WOPI app (OnlyOffice, Collabora...), it's
possible to disable the verification of the proof keys via configuration.

https://github.com/owncloud/ocis/pull/9366
7 changes: 7 additions & 0 deletions services/collaboration/pkg/config/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
4 changes: 4 additions & 0 deletions services/collaboration/pkg/config/defaults/defaultconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 54 additions & 0 deletions services/collaboration/pkg/middleware/proofkeys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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),
)

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)
})
}
3 changes: 3 additions & 0 deletions services/collaboration/pkg/middleware/wopicontext.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()).
Expand Down
280 changes: 280 additions & 0 deletions services/collaboration/pkg/proofkeys/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
package proofkeys

import (
"bytes"
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"errors"
"math/big"
"net/http"
"strconv"
"strings"
"time"

"github.com/beevik/etree"
"github.com/rs/zerolog"
)

type PubKeys struct {
Key *rsa.PublicKey
OldKey *rsa.PublicKey
ExpireTime time.Time
}

type Verifier interface {
Verify(accessToken, url, timestamp, sig64, oldSig64 string, opts ...VerifyOption) error
}

type VerifyHandler struct {
discoveryURL string
insecure bool
cachedKeys *PubKeys
cachedDur time.Duration
}

// 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,
cachedDur: cachedDur,
}
}

// Verify the request comes from a trusted source
// All the provided parameters are strings:
// * accessToken: The access token used for this request (targeting this collaboration service)
// * url: The full url for this request, including scheme, host and all query parameters,
// something like "https://wopiserver.test.private/wopi/file/abcbcbd?access_token=oiuiu" or
// "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
// * 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:
// * current signature with the current key
// * old signature with the current key
// * current signature with the old key
// 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, 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 {
return err
}

var oldSignature []byte
if oldSig64 != "" {
if oldSig, err := base64.StdEncoding.DecodeString(oldSig64); err != nil {
return err
} else {
oldSignature = oldSig
}
}

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
expectedProof := vh.generateProof(accessToken, url, timestamp)
hashedProof := sha256.Sum256(expectedProof)

// verify
if err := rsa.VerifyPKCS1v15(pubkeys.Key, crypto.SHA256, hashedProof[:], signature); err != nil {
if err := rsa.VerifyPKCS1v15(pubkeys.Key, crypto.SHA256, hashedProof[:], oldSignature); err != nil {
if pubkeys.OldKey != nil {
return rsa.VerifyPKCS1v15(pubkeys.OldKey, crypto.SHA256, hashedProof[:], signature)
} else {
return err
}
}
}
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 {
// set the stamp
stamp, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
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 := int64(62135596800 * 1000 * 1000 * 10)

// stamp - unixBaseStamp gives us the unix-based timestamp we can use
unixStamp := stamp - unixBaseStamp

// divide between 1000*1000*10 to get the seconds and 100-nanoseconds
unixStampSec := unixStamp / (1000 * 1000 * 10)
unixStampNanoSec := (unixStamp % (1000 * 1000 * 10)) * 100

// both seconds and nanoseconds should be within int64 range
convertedUnixTimestamp := time.Unix(unixStampSec, unixStampNanoSec)

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).
// The bytes will need to be hashed later in order to perform the verification
func (vh *VerifyHandler) generateProof(accessToken, url, timestamp string) []byte {
tokenBytes := []byte(accessToken)
tokenLen := len(tokenBytes)
tokenLenBytes := big.NewInt(int64(tokenLen)).FillBytes(make([]byte, 4))

// url needs to be uppercase
urlBytes := []byte(strings.ToUpper(url))
urlLen := len(urlBytes)
urlLenBytes := big.NewInt(int64(urlLen)).FillBytes(make([]byte, 4))

stampBigInt, _ := new(big.Int).SetString(timestamp, 10)
stampBytes := stampBigInt.FillBytes(make([]byte, 8))
stampLen := len(stampBytes)
stampLenBytes := big.NewInt(int64(stampLen)).FillBytes(make([]byte, 4))

proof := new(bytes.Buffer)
proof.Write(tokenLenBytes)
proof.Write(tokenBytes)
proof.Write(urlLenBytes)
proof.Write(urlBytes)
proof.Write(stampLenBytes)
proof.Write(stampBytes)
return proof.Bytes()
}

// fetchPublicKeys will fetch the public keys from the /hosting/discovery URL
// of the provided WOPI app.
// It will return a PubKeys struct to hold the public keys based on the modulus
// 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(logger *zerolog.Logger) (*PubKeys, error) {
httpClient := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: vh.insecure,
},
},
}

httpResp, err := httpClient.Get(vh.discoveryURL)
if err != nil {
logger.Error().
Err(err).
Str("WopiAppUrl", vh.discoveryURL).
Msg("WopiDiscovery: failed to access wopi app url")
return nil, err
}

defer httpResp.Body.Close()

if httpResp.StatusCode != http.StatusOK {
logger.Error().
Str("WopiAppUrl", vh.discoveryURL).
Int("HttpCode", httpResp.StatusCode).
Msg("WopiDiscovery: wopi app url failed with unexpected code")
return nil, err
}

doc := etree.NewDocument()
if _, err := doc.ReadFrom(httpResp.Body); err != nil {
return nil, err
}

root := doc.SelectElement("wopi-discovery")
if root == nil {
return nil, errors.New("wopi-discovery element not found in the XML body")
}

proofKey := root.SelectElement("proof-key")
if proofKey == nil {
return nil, errors.New("proof-key element not found in the XML body")
}

mod64 := proofKey.SelectAttrValue("modulus", "")
exp64 := proofKey.SelectAttrValue("exponent", "")
oldMod64 := proofKey.SelectAttrValue("oldmodulus", "")
oldExp64 := proofKey.SelectAttrValue("oldexponent", "")

if mod64 == "" || exp64 == "" {
return nil, errors.New("modulus or exponent not found in the proof-key element")
}

keys := &PubKeys{
Key: vh.keyFromBase64(mod64, exp64),
ExpireTime: time.Now().Add(vh.cachedDur),
}

if oldMod64 != "" && oldExp64 != "" {
keys.OldKey = vh.keyFromBase64(oldMod64, oldExp64)
}

return keys, nil
}

// keyFromBase64 will create a rsa public key from the provided modulus and
// exponent, both encoded with base64.
// If any of the provided strings can't be decoded, nil will be returned.
func (vh *VerifyHandler) keyFromBase64(mod64, exp64 string) *rsa.PublicKey {
dataMod, err := base64.StdEncoding.DecodeString(mod64)
if err != nil {
return nil
}

dataE, err := base64.StdEncoding.DecodeString(exp64)
if err != nil {
return nil
}

pub := &rsa.PublicKey{
N: new(big.Int).SetBytes(dataMod),
E: int(new(big.Int).SetBytes(dataE).Int64()),
}
return pub
}
Loading

0 comments on commit a7d8124

Please sign in to comment.