Several schemes for API interface parameter signatures

Several schemes for API interface parameter signature

When it comes to cross-system interface calls, we are prone to encounter the following security issues:

  • The requested identity has been forged.
  • The request parameters have been tampered with.
  • The request is captured and then the attack is replayed.

This article will explain step by step the steps that must be done when calling cross-system interfaces based on hypothetical demand scenarios, and the reasons why these steps are necessary.

1. Demand scenario

Suppose we have the following business requirements:

After the user successfully participates in the activity in system A, the activity reward is distributed to system B in the form of balance.

2. Initial plan: run naked

Without considering security issues, we can easily accomplish this requirement:

  • 1. Open an interface in system B.
/**
 * Add specified balance for specified user
 *
 * @param userId user id
 * @param money The balance to be added, unit: cents
 * @return/
 */
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money) {<!-- -->
    // Process business
    // ...
    
    // return 
    return JsonResult.ok();
}
  • 2. Use the http tool class in system A to call this interface.
long userId = 10001;
long money = 1000;
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + " & amp;money=" + money);

The above code simply fulfills the requirement, but it obviously has a security issue:

  • The open interface of system B can be called not only by system A, but also by anyone else. Others can even run a for loop locally to call this interface and recharge unlimited amounts for themselves.

3. Solution upgrade: add secretKey verification

In order to prevent the open interface of system B from being called arbitrarily by strangers, we add a secretKey parameter

//Add the specified balance for the specified user
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money, String secretKey) {<!-- -->
    // 1. First verify whether the secretKey parameter is correct. If it is incorrect, directly refuse to respond to the request.
    if( ! check(secretKey) ) {<!-- -->
        return JsonResult.error("Invalid secretKey, unable to respond to request");
    }
    
    // 2. Business code
    // ...
    
    // 3. Return
    return JsonResult.ok();
}

Since system A is our “own”, it can make legal requests with the secretKey:

long userId = 10001;
long money = 1000;
String secretKey = "xxxxxxxxxxxxxxxxxxxx";
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + " & amp;money=" + money + " & amp;secretKey=" + secretKey);

Now, even if the interface of system B is exposed, it will not be called arbitrarily by strangers. Security is guaranteed to a certain extent, but there are still some problems:

  • If the request is captured, the secretKey will be leaked because the secretKey parameter is transmitted in clear text in the url for every request.
  • If the request is captured, other parameters of the request can be modified arbitrarily. For example, the money parameter can be modified to 9999999. System B cannot determine whether the parameters have been modified.

4. Upgrade the solution: use the digest algorithm to generate parameter signatures

First, do not initiate a request directly in system A, but first calculate a sign parameter:

//Declare variables
long userId = 10001;
long money = 1000;
String secretKey = "xxxxxxxxxxxxxxxxxxxx";

// Calculate the sign parameter
String sign = md5("money=" + money + " & amp;userId=" + userId + " & amp;key=" + secretKey);

// Splice sign after the request address
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + " & amp;money=" + money + " & amp;sign=" + sign);

Note that when calculating the signature here, all parameters need to be arranged in dictionary order (except key, which is at the end). The following calculations of signatures are the same and will not be repeated.

Then when system B receives the request, it uses the same algorithm and the same secret key to generate the sign string and compares it with the sign value in the parameter:

//Add the specified balance for the specified user
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money, String sign) {<!-- -->

    // In system B, use the same algorithm and the same key to calculate sign2 and compare it with the incoming sign
    String sign2 = md5("money=" + money + " & amp;userId=" + userId + " & amp;key=" + secretKey);
    if( ! sign2.equals(sign)) {<!-- -->
        return JsonResult.error("Invalid sign, unable to respond to request");
    }

    // 2. Business code
    // ...
    
    // 3. Return
    return JsonResult.ok();
}

Because the value of sign is determined by the three parameters of userId, money, and secretKey, as long as one parameter is inconsistent, the final generated sign will also be inconsistent. Therefore, according to the comparison results:

If the sign matches, it means this is a legitimate request.
If the sign is inconsistent, it means that the client secret key that initiated the request is incorrect, or the request parameters have been tampered with, making it an illegal request.
Advantages of this solution:

The secretKey parameter is no longer passed directly in the url to avoid the risk of leakage.
Due to the limitations of the sign parameter, the parameters in the request cannot be tampered with, and System B can use these parameters with confidence.
This solution still has the following flaws:

  • After the packet is captured, the request can be replayed indefinitely. System B cannot determine whether the request actually comes from system A or is replayed after the packet is captured.

5. Upgrade the solution again: append nonce random string

First, before system A initiates the call, add a nonce parameter to participate in the signature:

//Declare variables
long userId = 10001;
long money = 1000;
String nonce = RandomUtil.getRandomString(32); // Random 32-bit string
String secretKey = "xxxxxxxxxxxxxxxxxxxx";

// Calculate the sign parameter
String sign = md5("money=" + money + " & amp;nonce=" + nonce + " & amp;userId=" + userId + " & amp;key=" + secretKey) ;

// Splice sign after the request address
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + " & amp;money=" + money + "nonce=" + nonce + \ " & amp;sign=" + sign);

Then when system B receives the request, it also adds the nonce parameter to generate the sign string for comparison:

//Add the specified balance for the specified user
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money, String nonce, String sign) {<!-- -->

    // 1. Check whether this nonce has been used
    if(CacheUtil.get("nonce_" + nonce) != null) {<!-- -->
        return JsonResult.error("This nonce has been used, the request is invalid");
    }

    // 2. Verify signature
    String sign2 = md5("money=" + money + " & amp;nonce=" + nonce + " & amp;userId=" + userId + " & amp;key=" + secretKey) ;
    if( ! sign2.equals(sign)) {<!-- -->
        return JsonResult.error("Invalid sign, unable to respond to request");
    }

    // 3. Record the nonce in the cache to prevent reuse
    CacheUtil.set("nonce_" + nonce, "1");

    // 4. Business code
    // ...

    // 5. Return
    return JsonResult.ok();
}

Code analysis:

For ease of understanding, let’s look at step 3 first: here, after the signature is successfully verified, the nonce random string is recorded in the cache.
Let’s look at step 1 again: every time a request comes in, first check whether this random string has been recorded in the cache. If so, it will immediately return: invalid request.
The combination of these two steps ensures that a nonce random string can only be used once. If the request is captured and replayed, it will not pass the nonce verification.

At this point, the problem seems to have been solved…?

Don’t worry, there is another question we haven’t considered: how long should this nonce string be stored in the cache?

  • Save for 15 minutes? The person who captured the packet only needs to wait 15 minutes for your nonce record to disappear from the cache and the request can be replayed.
  • What about saving for 24 hours? Keep it for a week? Keep it for half a month? It seems that no matter how long it is stored, it cannot fundamentally solve this problem.
  • You may think, then I will keep it forever. This can indeed solve the problem, but obviously the server cannot bear it. Even the smallest amount of data will exceed the upper limit of the server’s capacity over time.

6. Upgrade the plan again: add timestamp timestamp

We can append another timestamp timestamp parameter to limit the validity of the request to a limited time range, such as 15 minutes.

First, add the timestamp parameter in system A:

//Declare variables
long userId = 10001;
long money = 1000;
String nonce = RandomUtil.getRandomString(32); // Random 32-bit string
long timestamp = System.currentTimeMillis(); // Random 32-bit string
String secretKey = "xxxxxxxxxxxxxxxxxxxx";

// Calculate the sign parameter
String sign = md5("money=" + money + " & amp;nonce=" + nonce + " & amp;timestamp=" + timestamp + " & amp;userId=" + userId + " & amp;key=" + secretKey);

// Splice sign after the request address
String res = HttpUtil.request("http://b.com/api/addMoney" +
        "?userId=" + userId + " & amp;money=" + money + " & amp;nonce=" + nonce + " & amp;timestamp=" + timestamp + " & amp ;sign=" + sign);

Check whether this timestamp exceeds the allowed range in system B.

//Add the specified balance for the specified user
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money, long timestamp, String nonce, String sign) {<!-- -->

    // 1. Check whether timestamp exceeds the allowed range (assuming here that the maximum allowed gap is 15 minutes)
    long timestampDisparity = System.currentTimeMillis() - timestamp; // Actual time difference
    if(timestampDisparity > 1000 * 60 * 15) {<!-- -->
        return JsonResult.error("The timestamp time difference exceeds the allowed range, the request is invalid");
    }

    // 2. Check whether this nonce has been used
    //The code is the same as above and will not be repeated.

    // 3. Verify signature
    //The code is the same as above and will not be repeated.

    // 4. Record the nonce into the cache, and the ttl validity period is consistent with the allowDisparity allowed time difference.
    CacheUtil.set("nonce_" + nonce, "1", 1000 * 60 * 15);

    // 5. Business code...

    // 6. Return
    return JsonResult.ok();
}

At this point, the packet capturer:

  • If the attack is replayed within 15 minutes, the nonce parameter is not allowed: the nonce value can be found in the cache and the response request is directly refused.
  • If the attack is replayed after 15 minutes, the timestamp parameter is not allowed: the allowed timestamp time difference is exceeded and the response request is directly refused.

7. Server clock differences cause security issues

The above code assumes that the clocks of system A server and system B server are consistent so that security verification can be completed normally. However, in actual development scenarios, some servers may have inaccurate clocks.

Assume that the clock difference between server A and server B is 10 minutes, that is, when server A is 8:00, server B is 7:50.

System A initiates a request, and the timestamp it generates also represents 8:00.
After system B receives the request, it completes the business processing. At this time, the ttl of the nonce is 15 minutes, and the expiration time is 7:50 + 15 minutes = 8:05.

After 8.05, the nonce cache disappeared, and the packet capturer replayed the request attack:

  • Timestamp verification passes: Because the timestamp difference is only 8.05 – 8.00 = 5 minutes, which is less than 15 minutes, the verification passes.
  • The nonce verification passes: Because the nonce cache has disappeared at this time, the verification can pass.
  • The sign verification passed: because this is a legal signature constructed by system A.

The attack is complete.

To solve the above problem, there are two options:

  • Solution 1: Modify the server clock to make the two server clocks consistent.
  • Solution 2: Compatible with clock inconsistent scenarios at the code level.

Students who want to adopt option one can search for the method of synchronizing the clock by themselves. I will not go into details here. Here, I will elaborate on option two.

We only need to simply modify the code of system B’s verification parameters:

//Add the specified balance for the specified user
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money, long timestamp, String nonce, String sign) {<!-- -->

    // 1. Check whether timestamp exceeds the allowed range (Key point 1: the absolute value needs to be taken here)
    long timestampDisparity = Math.abs(System.currentTimeMillis() - timestamp);
    if(timestampDisparity > 1000 * 60 * 15) {<!-- -->
        return JsonResult.error("The timestamp time difference exceeds the allowed range, the request is invalid");
    }

    // 2. Check whether this nonce has been used
    //The code is the same as above and will not be repeated.

    // 3. Verify signature
    //The code is the same as above and will not be repeated.

    // 4. Record the nonce in the cache to prevent reuse (Key point 2: Here you need to set ttl to a value x 2 that allows timestamp time difference)
    CacheUtil.set("nonce_" + nonce, "1", (1000 * 60 * 15) * 2);

    // 5. Business code...

    // 6. Return
    return JsonResult.ok();
}

8. Final plan

Paste the complete code here again.

System A (initiating the requesting end):

//Declare variables
long userId = 10001;
long money = 1000;
String nonce = RandomUtil.getRandomString(32); // Random 32-bit string
long timestamp = System.currentTimeMillis(); // current timestamp
String secretKey = "xxxxxxxxxxxxxxxxxxxx";

// Calculate the sign parameter
String sign = md5("money=" + money + " & amp;nonce=" + nonce + " & amp;timestamp=" + timestamp + " & amp;userId=" + userId + " & amp;key=" + secretKey);

// Splice sign after the request address
String res = HttpUtil.request("http://b.com/api/addMoney" +
        "?userId=" + userId + " & amp;money=" + money + " & amp;nonce=" + nonce + " & amp;timestamp=" + timestamp + " & amp ;sign=" + sign);

System B (receiving request end):

//Add the specified balance for the specified user
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money, long timestamp, String nonce, String sign) {<!-- -->

    // 1. Check whether timestamp exceeds the allowed range
    long allowDisparity = 1000 * 60 * 15; // Allowed time difference: 15 minutes
    long timestampDisparity = Math.abs(System.currentTimeMillis() - timestamp); // Actual time difference
    if(timestampDisparity > allowDisparity) {<!-- -->
        return JsonResult.error("The timestamp time difference exceeds the allowed range, the request is invalid");
    }

    // 2. Check whether this nonce has been used
    if(CacheUtil.get("nonce_" + nonce) != null) {<!-- -->
        return JsonResult.error("This nonce has been used, the request is invalid");
    }

    // 3. Verify signature
    String sign2 = md5("money=" + money + " & amp;nonce=" + nonce + " & amp;timestamp=" + timestamp + " & amp;userId=" + userId + " & amp;key=" + secretKey);
    if( ! sign2.equals(sign)) {<!-- -->
        return JsonResult.error("Invalid sign, unable to respond to request");
    }

    // 4. Record the nonce in the cache to prevent reuse. Note that ttl needs to be set to the value x 2 that allows the timestamp time difference.
    CacheUtil.set("nonce_" + nonce, "1", allowDisparity * 2);

    // 5. Business code...

    // 6. Return
    return JsonResult.ok();
}

If you find any deficiencies while reading, please leave a message! ! !