Verifying that requests originate from Office Online by using proof keys

When processing WOPI requests from Office Online, you might want to verify that these requests are coming from Office Online. To do this, you use proof keys.

Office Online signs every WOPI request with a private key. The corresponding public key is available in the proof-key element in the WOPI discovery XML. The signature is sent with every request in the X-WOPI-Proof and X-WOPI-ProofOld HTTP headers.

The signature is assembled from information that is available to the WOPI host when it processes the incoming WOPI request. To verify that a request came from Office Online, you must:

  • Create the expected value of the proof headers.
  • Use the public key provided in WOPI discovery to decrypt the proof provided in the X-WOPI-Proof header.
  • Compare the expected proof to the decrypted proof. If they match, the request originated from Office Online.
  • Ensure that the X-WOPI-TimeStamp header is no more than 20 minutes old.

Note

Requests to the FileUrl will not be signed. The FileUrl is used exactly as provided by the host, so it does not necessarily include the access token, which is required to construct the expected proof.

Tip

The Office Online GitHub repository contains a set of unit tests that hosts can adapt to verify proof key validation implementations. See Proof key unit tests for more information.

Constructing the expected proof

To construct the expected proof, you must assemble a byte array consisting of the access token, the URL of the request (in uppercase), and the value of the X-WOPI-TimeStamp HTTP header from the request. Each of these values must be converted to a byte array. In addition, you must include the length, in bytes, of each of these values.

To convert the access token and request URL values, which are strings, to byte arrays, you must ensure the original strings are in UTF-8 first, then convert the UTF-8 strings to byte arrays. Convert the X-WOPI-TimeStamp header to a long and then into a byte array. Do not treat it as a string.

Then, assemble the data as follows:

  • 4 bytes that represent the length, in bytes, of the access_token on the request.
  • The access_token.
  • 4 bytes that represent the length, in bytes, of the full URL of the WOPI request, including any query string parameters.
  • The WOPI request URL in all uppercase. All query string parameters on the request URL should be included.
  • 4 bytes that represent the length, in bytes, of the X-WOPI-TimeStamp value.
  • The X-WOPI-TimeStamp value.

The following code samples illustrate the construction of an expected proof in C#, Java, and Python.

Code sample 3 Constructing the expected in proof in C#
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
public bool Validate(ProofKeyValidationInput testCase)
{
    // Encode values from headers into byte[]
    var accessTokenBytes = Encoding.UTF8.GetBytes(testCase.AccessToken);
    var hostUrlBytes = Encoding.UTF8.GetBytes(testCase.Url.ToUpperInvariant());
    var timeStampBytes = EncodeNumber(testCase.Timestamp);

    // prepare a list that will be used to combine all those arrays together
    List<byte> expectedProof = new List<byte>(
        4 + accessTokenBytes.Length +
        4 + hostUrlBytes.Length +
        4 + timeStampBytes.Length);

    expectedProof.AddRange(EncodeNumber(accessTokenBytes.Length));
    expectedProof.AddRange(accessTokenBytes);
    expectedProof.AddRange(EncodeNumber(hostUrlBytes.Length));
    expectedProof.AddRange(hostUrlBytes);
    expectedProof.AddRange(EncodeNumber(timeStampBytes.Length));
    expectedProof.AddRange(timeStampBytes);

    // create another byte[] from that list
    byte[] expectedProofArray = expectedProof.ToArray();

    // validate it against current and old keys in proper combinations
    bool validationResult =
        TryVerification(expectedProofArray, testCase.Proof, _currentKey.CspBlob) ||
        TryVerification(expectedProofArray, testCase.OldProof, _currentKey.CspBlob) ||
        TryVerification(expectedProofArray, testCase.Proof, _oldKey.CspBlob);

    // TODO:
    // in real code you should also check that TimeStamp header is no more than 20 minutes old
    // but because we're using predefined test cases to validate that the method works
    // we can't do it here.
    return validationResult;
}

Retrieving the public key

Office Online provides two different public keys as part of the WOPI discovery XML: the current key and the old key. Two keys are necessary because the discovery data is meant to be cached by the host, and Office Online periodically rotates the keys it uses to sign requests. When the keys are rotated, the current key becomes the old key, and a new current key is generated. This helps to minimize the risk that a host does not have updated key information from WOPI discovery when Office Online rotates keys.

Both keys are represented in the discovery XML in two different formats. One format is for WOPI hosts that use the .NET framework. The other format can be imported in a variety of different programming languages and platforms.

Using .NET to retrieve the public key

If your application is built on the .NET framework, you should use the contents of the value and oldvalue attributes of the proof-key element in the WOPI discovery XML. These two attributes contain the Base64-encoded public keys that are exported by using the RSACryptoServiceProvider.ExportCspBlob method of the .NET Framework.

To import this key in your application, you must decode it from Base64 then import it by using the RSACryptoServiceProvider.ImportCspBlob method.

Using the RSA modulus and exponent to retrieve the public key

For hosts that don’t use the .NET framework, Office Online provides the RSA modulus and exponent directly. The modulus and exponent of the current key are found in the modulus and exponent attributes of the proof-key element in the WOPI discovery XML. The modulus and exponent of the old key are found in the oldmodulus and oldexponent attributes. All four of these values are Base64-encoded.

The steps to import these values differ based on the language, platform, and cryptography API that you are using.

The following examples show how to import the public key by using the modulus and exponent in both Java and Python (using the PyCrypto library).

Java

Code sample 4 Generating a public key from a modulus and exponent in Java
121
122
123
124
125
126
127
128
129
private static RSAPublicKey getPublicKey( String modulus, String exponent ) throws Exception
{
    BigInteger mod = new BigInteger( 1, DatatypeConverter.parseBase64Binary( modulus ) );
    BigInteger exp = new BigInteger( 1, DatatypeConverter.parseBase64Binary( exponent ) );
    KeyFactory factory = KeyFactory.getInstance( "RSA" );
    KeySpec ks = new RSAPublicKeySpec( mod, exp );

    return (RSAPublicKey) factory.generatePublic( ks );
}

Python

Code sample 5 Generating a public key from a modulus and exponent in Python
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def generate_key(modulus_b64, exp_b64):
    """
    Generates an RSA public key given a base64-encoded modulus and exponent
    :param modulus_b64: base64-encoded modulus
    :param exp_b64: base64-encoded exponent
    :return: an RSA public key
    """
    mod = int(b64decode(modulus_b64).encode('hex'), 16)
    exp = int(b64decode(exp_b64).encode('hex'), 16)
    seq = asn1.DerSequence()
    seq.append(mod)
    seq.append(exp)
    der = seq.encode()
    return RSA.importKey(der)

Verifying the proof keys

After you import the key, you can use a verification method provided by your cryptography library to verify incoming requests were signed by Office Online. Because Office Online rotates the current and old proof keys periodically, you have to check three combinations of proof key values:

  • The X-WOPI-Proof value using the current public key
  • The X-WOPI-ProofOld value using the current public key
  • The X-WOPI-Proof value using the old public key

If any one of the values is valid, the request was signed by Office Online.

The following examples show how to verify one of these combinations in C#, Java, and Python.

Verification in C#

Code sample 6 Sample proof key validation code in C#
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
private static bool TryVerification(byte[] expectedProof,
    string signedProof,
    string publicKeyCspBlob)
{
    using(RSACryptoServiceProvider rsaAlg = new RSACryptoServiceProvider())
    {
        byte[] publicKey = Convert.FromBase64String(publicKeyCspBlob);
        byte[] signedProofBytes = Convert.FromBase64String(signedProof);
        try
        {
            rsaAlg.ImportCspBlob(publicKey);
            return rsaAlg.VerifyData(expectedProof, "SHA256", signedProofBytes);
        }
        catch(FormatException)
        {
            return false;
        }
        catch(CryptographicException)
        {
            return false;
        }
    }
}

Verification in Java

Code sample 7 Sample proof key validation code in Java
 98
 99
100
101
102
103
104
105
106
107
108
109
110
public static boolean verifyProofKey( String strModulus, String strExponent,
    String strWopiProofKey, byte[] expectedProofArray ) throws Exception
{
    PublicKey publicKey = getPublicKey( strModulus, strExponent );

    Signature verifier = Signature.getInstance( "SHA256withRSA" );
    verifier.initVerify( publicKey );
    verifier.update( expectedProofArray ); // Or whatever interface specifies.

    final byte[] signedProof = DatatypeConverter.parseBase64Binary( strWopiProofKey );

    return verifier.verify( signedProof );
}

Verification in Python

Code sample 8 Sample proof key validation code in Python
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def try_verification(expected_proof, signed_proof_b64, public_key):
    """
    Verifies the signature of a signed WOPI request using a public key provided in
    WOPI discovery.
    :param expected_proof: a bytearray of the expected proof data
    :param signed_proof_b64: the signed proof key provided in the X-WOPI-Proof or
    X-WOPI-ProofOld headers. Note that the header values are base64-encoded, but
    will be decoded in this method
    :param public_key: the public key provided in WOPI discovery
    :return: True if the request was signed with the private key corresponding to
    the public key; otherwise, False
    """
    signed_proof = b64decode(signed_proof_b64)
    verifier = PKCS1_v1_5.new(public_key)
    h = SHA256.new(expected_proof)
    v = verifier.verify(h, signed_proof)
    return v

Troubleshooting proof key implementations

If you are having difficulty with your proof key verification implementation, here are some common issues that you should investigate:

  • Verify you’re converting the URL to uppercase.
  • Verify you’re including any query string parameters on the URL when transforming it for the purposes of building the expected proof value.
  • Verify you’re using the same encoding for any special characters that may be in the URL.
  • Verify you’re using an HTTPS URL if your WOPI endpoints are HTTPS. This is especially important if you have SSL termination in your network prior to your WOPI request handlers. In this case, the URL Office Online will use to sign the requests will be HTTPS, but the URL your WOPI handlers ultimately receive will be HTTP. If you simply use the incoming request URL your expected proof will not match the signature provided by Office Online.

In addition, use the Proof key unit tests to verify your implementation with sample data.