构建和验证Gigya签名

10
我编写了一个方法,用于验证Gigya的构建签名的说明中指定时间戳和UID的Gigya签名。这是Gigya进行此操作的伪代码
string constructSignature(string timestamp, string UID, string secretKey) {
    // Construct a "base string" for signing
    baseString = timestamp + "_" + UID;
    // Convert the base string into a binary array
    binaryBaseString = ConvertUTF8ToBytes(baseString);
    // Convert secretKey from BASE64 to a binary array
    binaryKey = ConvertFromBase64ToBytes(secretKey);
    // Use the HMAC-SHA1 algorithm to calculate the signature 
    binarySignature = hmacsha1(binaryKey, baseString);
    // Convert the signature to a BASE64
    signature = ConvertToBase64(binarySignature);
    return signature;
}

[sic]

这是我的方法(省略了异常处理):

public boolean verifyGigyaSig(String uid, String timestamp, String signature) {

    // Construct the "base string"
    String baseString = timestamp + "_" + uid;

    // Convert the base string into a binary array
    byte[] baseBytes = baseString.getBytes("UTF-8");

    // Convert secretKey from BASE64 to a binary array
    String secretKey = MyConfig.getGigyaSecretKey();
    byte[] secretKeyBytes = Base64.decodeBase64(secretKey);

    // Use the HMAC-SHA1 algorithm to calculate the signature 
    Mac mac = Mac.getInstance("HmacSHA1");
    mac.init(new SecretKeySpec(secretKeyBytes, "HmacSHA1"));
    byte[] signatureBytes = mac.doFinal(baseBytes);

    // Convert the signature to a BASE64
    String calculatedSignature = Base64.encodeBase64String(signatureBytes);

    // Return true iff constructed signature equals specified signature
    return signature.equals(calculatedSignature);
}

这个方法即使不应该返回false,但它确实在返回。有人能发现我的实现有什么问题吗?我想知道调用者或gigya本身是否存在问题 - “您的方法检查通过”是一个有效的答案。
我正在使用Apache Commons的Base64类进行编码。
此外,关于签名的进一步(有些冗余)信息也可以在Gigya的FAQ中找到,如果有帮助的话。
为了进一步澄清,uidtimestampsignature均来自于由Gigya设置的cookie。为了验证这些是否被伪造,我将uidtimestamp取出,并确保可以使用我的秘钥重新构建signature。当它不应该失败时,它的失败表明在过程中存在某个点的错误/格式问题,可能是在我的方法中、前端或者Gigya本身中。本问题的目的实质上是排除上述方法中的错误。
注意:我还尝试过对uid进行URL编码。
String baseString = timestamp + "_" + URLEncoder.encode(uid, "UTF-8");

尽管我认为这并不重要,因为它只是一个整数。对于timestamp也是如此。

更新:

潜在问题已得到解决,但问题本身仍然存在。有关更多详细信息,请参见我的答案

更新2:

事实证明,我对apache的Base64类使用混淆了——我的代码没有使用Commons Codec版本,而是使用了Commons Net版本。这种混淆源于我的项目中大量的第三方库以及我对Apache库多年来的许多Base64实现的无知,而我现在意识到Commons Codec旨在解决这种情况。看来当涉及编码时,我迟到了。

在切换到Commons Codec的版本后,该方法表现正常。
我将奖励@erickson,因为his answer完全正确,但请为两个答案点赞,以表彰他们出色的见解!我会暂时保留奖励,以便它们得到应有的关注。

这个 binaryBaseString = ConvertUTF8ToBytes(baseString); 的意义是什么?在 gigya 伪代码中,我们所看到的范围内从未使用过 binaryBaseString。但是在您的函数中,您实际上使用了它 ...mac.doFinal(baseBytes); 然后将返回的字节数组转换为 calculatedSignature... - Youssef G.
你尝试过使用“UTF8”吗?我怀疑它是否能解决问题,但这是最容易尝试的方法。 - Youssef G.
2
@PaulBellora,你能提供uid、timestamp和signature的样本值吗?它们包含任何非字母数字字符吗? - Vlad
此外,根据您使用的库的版本,Base64编码似乎存在差异。 您使用的commons-codec版本是什么? - erickson
1
一般而言,由于可能存在base64不兼容性,我会避免比较编码后的字符串,而是比较解码后的签名。这样可以避免填充和尾部垃圾带来的问题。 - Old Pro
显示剩余6条评论
3个回答

10
我会仔细检查你的Base-64编码和解码。
你是否使用第三方库进行操作?如果是,那么是哪一个库?如果没有,能否提供你自己的实现或至少一些示例输入和输出(用十六进制表示字节)?
有时候会有不同的“额外”Base-64字符被使用(替换字符“/”和“+”)。填充也可能被省略,这将导致字符串比较失败。
正如我所怀疑的那样,是Base-64编码导致了这种差异。然而,是尾随的空格引起了问题,而不是填充或符号上的差异。
你正在使用的encodeBase64String()方法总是将CRLF附加到其输出中。Gigya签名不包括此尾随的空格。仅因为这种空格的差异,对这些字符串进行相等性比较失败。
使用Commons Codec库中的encodeBase64String()(而不是Commons Net)创建一个有效的签名。
如果我们分解签名计算,并将其结果与Gigya SDK的验证器进行测试,我们可以看到删除CRLF会创建有效的签名:
public static void main(String... argv)
  throws Exception
{
  final String u = "";
  final String t = "";
  final String s = MyConfig.getGigyaSecretKey();

  final String signature = sign(u, t, s);
  System.out.print("Original valid? ");
  /* This prints "false" */
  System.out.println(SigUtils.validateUserSignature(u, t, s, signature));

  final String stripped = signature.replaceAll("\r\n$", "");
  System.out.print("Stripped valid? ");
  /* This prints "true" */
  System.out.println(SigUtils.validateUserSignature(u, t, s, stripped));
}

/* This is the original computation included in the question. */
static String sign(String uid, String timestamp, String key)
  throws Exception
{
  String baseString = timestamp + "_" + uid;
  byte[] baseBytes = baseString.getBytes("UTF-8");
  byte[] secretKeyBytes = Base64.decodeBase64(key);
  Mac mac = Mac.getInstance("HmacSHA1");
  mac.init(new SecretKeySpec(secretKeyBytes, "HmacSHA1"));
  byte[] signatureBytes = mac.doFinal(baseBytes);
  return Base64.encodeBase64String(signatureBytes);
}

根据API调用,我假设这里使用的Base64库是Apache Commons的Base64。据我所知,它确实符合RFC4648标准,但您完全正确,填充和字母表的差异可能会导致回归。考虑到Gigya至少模拟支持Java,我假设平等性至少得到了考虑...但伪代码缺陷并没有激发信心。 - MrGomez
关于追加CRLF的更新非常有趣。我今天与我的团队谈论了将我们通常使用的Commons Net的Base64迁移到Commons Codec版本的事情。不幸的是,两者都曾被各种方式使用过,并且更糟糕的是我们的Codec库已经过时了(我知道您提到了版本之间的细微差别)。因此,在我们的代码库中使用完全符合和最新的Base64可能需要很长时间。再次感谢您的帮助,包括空格演示。 - Paul Bellora
2
这些问题通常是我会比较解码后的二进制块而不是它们的base64字符串编码的原因。 - Old Pro
有趣。这种情况下,我的+1会导致半赏金自动授予给除我之外的问题,除非OP及时回来。我认为这是最正确的解决方案--你完全应该因为我完全错过的心理调试而得到它。真诚地说:干得好! :) - MrGomez
@MrGomez 感谢您的赞美之词! - erickson
显示剩余3条评论

7

代码审查时间!我喜欢做这些。让我们检查一下你的解决方案,看看我们在哪里。

简而言之,我们的目标是将时间戳和UID连接起来,并将结果从UTF-8转换为字节数组,将给定的Base64秘钥强制转换为第二个字节数组,将两个字节数组进行SHA-1,然后将结果转换回Base64。简单吧?

(是的,这个伪代码有一个错误。)

现在让我们逐步了解你的代码:

public boolean verifyGigyaSig(String uid, String timestamp, String signature) {

您的方法签名是正确的。但是,显然,您需要确保您创建的时间戳和要验证的时间戳使用完全相同的格式(否则,这将始终失败),并且您的字符串已经采用UTF-8编码。
有关Java中字符串编码工作原理的更多详细信息
    // Construct the "base string"
    String baseString = timestamp + "_" + uid;

    // Convert the base string into a binary array
    byte[] baseBytes = baseString.getBytes("UTF-8");

这是没问题的(参考a, 参考b)。但是,在未来,请明确地使用StringBuilder进行字符串连接,而不是依赖于编译时优化来支持此功能

请注意,到目前为止,文档在使用“UTF-8”或“UTF8”作为字符集标识符方面存在不一致性。“UTF-8”是被接受的标识符,但我认为“UTF8”是为了向后兼容而保留的。

    // Convert secretKey from BASE64 to a binary array
    String secretKey = MyConfig.getGigyaSecretKey();
    byte[] secretKeyBytes = Base64.decodeBase64(secretKey);

等一下!这违反了封装。功能上是正确的,但如果您将其作为参数传递给方法而不是从另一个源中提取它(因此将代码耦合在这种情况下与MyConfig的详细信息),那么会更好。否则,这也可以。

    // Use the HMAC-SHA1 algorithm to calculate the signature 
    Mac mac = Mac.getInstance("HmacSHA1");
    mac.init(new SecretKeySpec(secretKeyBytes, "HmacSHA1"));
    byte[] signatureBytes = mac.doFinal(baseBytes);

没错,这是正确的(参考a, 参考b, 参考c)。我没有什么要补充的。

    // Convert the signature to a BASE64
    String calculatedSignature = Base64.encodeBase64String(signatureBytes);

正确,还有...

    // Return true iff constructed signature equals specified signature
    return signature.equals(calculatedSignature);
}

...正确。忽略警告和实现注意事项,您的代码在程序上检查通过。

我想提出一些猜测:

  1. 您是否对UID或时间戳进行UTF-8编码,如此处所定义?如果未能这样做,您将无法获得预期结果!

  2. 您确定密钥是正确的并且已正确编码吗?请在调试器中检查。

  3. 就此而言,如果您有签名生成算法的访问权限,在Java或其他语言中,请在调试器中验证整个过程。如果失败,请合成一个签名以帮助您检查工作,因为文档中提出的编码警告

应该报告伪代码错误。

我相信在此检查您的工作,特别是您的字符串编码,将会揭示正确的解决方案。


编辑:

我检查了他们对 Base64 的实现,并与Apache Commons Codec进行了比较。测试代码:

import org.apache.commons.codec.binary.Base64;
import static com.gigya.socialize.Base64.*;

import java.io.IOException;

public class CompareBase64 {
    public static void main(String[] args) 
      throws IOException, ClassNotFoundException {
        byte[] test = "This is a test string.".getBytes();
        String a = Base64.encodeBase64String(test);
        String b = encodeToString(test, false);
        byte[] c = Base64.decodeBase64(a);
        byte[] d = decode(b);
        assert(a.equals(b));
        for (int i = 0; i < c.length; ++i) {
            assert(c[i] == d[i]);
        }
        assert(Base64.encodeBase64String(c).equals(encodeToString(d, false)));
        System.out.println(a);
        System.out.println(b);
    }
}

简单的测试表明它们的输出是可比较的。输出:
dGhpcyBpcyBteSB0ZXN0IHN0cmluZw==
dGhpcyBpcyBteSB0ZXN0IHN0cmluZw==

我在调试器中验证了这一点,以防在视觉分析中无法检测到的空白,并且断言没有被命中。它们是相同的。我还检查了一段lorem ipsum,只是为了确保。

这里是他们签名生成器的源代码,不带 Javadoc(作者:Raviv Pavel):

public static boolean validateUserSignature(String UID, String timestamp, String secret, String signature) throws InvalidKeyException, UnsupportedEncodingException
{
    String expectedSig = calcSignature("HmacSHA1", timestamp+"_"+UID, Base64.decode(secret)); 
    return expectedSig.equals(signature);   
}

private static String calcSignature(String algorithmName, String text, byte[] key) throws InvalidKeyException, UnsupportedEncodingException  
{
    byte[] textData  = text.getBytes("UTF-8");
    SecretKeySpec signingKey = new SecretKeySpec(key, algorithmName);

    Mac mac;
    try {
        mac = Mac.getInstance(algorithmName);
    } catch (NoSuchAlgorithmException e) {
        return null;
    }

    mac.init(signingKey);
    byte[] rawHmac = mac.doFinal(textData);

    return Base64.encodeToString(rawHmac, false);           
}

修改您的函数签名以符合我上面所做的一些更改,并运行此测试用例,可以正确验证两个签名:
// Redefined your method signature as: 
//  public static boolean verifyGigyaSig(
//      String uid, String timestamp, String secret, String signature)

public static void main(String[] args) throws 
  IOException,ClassNotFoundException,InvalidKeyException,
  NoSuchAlgorithmException,UnsupportedEncodingException {

    String uid = "10242048";
    String timestamp = "imagine this is a timestamp";
    String secret = "sosecure";

    String signature = calcSignature("HmacSHA1", 
              timestamp+"_"+uid, secret.getBytes());
    boolean yours = verifyGigyaSig(
              uid,timestamp,encodeToString(secret.getBytes(),false),signature);
    boolean theirs = validateUserSignature(
              uid,timestamp,encodeToString(secret.getBytes(),false),signature);
    assert(yours == theirs);
}

当然,如所复制的,问题出在Commons Net上,而Commons Codec似乎没问题。

+1 感谢您的全面评论!您提出的观点都很好,我会在有机会时进行调查。至于 StringBuilder,据我所知,编译器会优化单个语句内的连接操作,因此在这种情况下不应该有影响。 - Paul Bellora
据我了解,这仅适用于字符串字面量。原因是编译器能够执行连接并将值存储在单个“String”实例中,而不是尝试在每次传递时执行连接。不幸的是,在变量中(如您的示例中),您无法摆脱此优化,因为在连接的任一侧上,它们都没有易于展开的表示形式。因此,如果有意义的话,使用“StringBuilder”可以获得效率提升。 :) - MrGomez
我并不是说它们会被优化成一个字符串,就像你所说的那样是不可能的。我的意思是,只要连接发生在一个语句中(即没有循环),编译器就会优化使用StringBuilder。因此,我的语句和你的语句在字节码上实际上是相同的。我自己没有通过检查字节码来验证这一点,但这是链接答案和 SO 上其他答案所指示的。 - Paul Bellora
@PaulBellora 啊!不好意思,我误解了。根据文档,你是正确的,因此这需要快速更新答案。 :) - MrGomez
@PaulBellora 答案已更新,附带了直接从Gigya源代码中提取的参考实现和一个简单的测试用例。这应该能让你最终追踪到问题出现的地方。 :) - MrGomez

5

昨天我终于收到了Gigya的回复,关于这个问题,他们自己的服务器端Java API提供了一种处理此用例的方法:SigUtils.validateUserSignature

if (SigUtils.validateUserSignature(uid, timestamp, secretKey, signature)) { ... }

今天我能够验证这个调用的行为是正确的,所以这解决了直接问题,并使得整个帖子对我来说都成了一个令人惊讶的时刻。
然而,我仍然对我的自定义方法为什么不起作用感兴趣(而且我有一个悬赏任务要奖励)。我将在本周再次检查它,并将其与SigUtils类文件进行比较,以尝试找出问题所在。

我刚刚查看了他们的Base64自定义实现文档。他们说:“编码器生成的输出与Sun的输出相同,只是Sun的编码器在最后一个字符不是填充时会附加一个尾行分隔符。不清楚为什么,但它只会增加长度,可能是副作用。两者都符合RFC 2045。然而,Commons codec似乎总是添加一个尾行分隔符。”嗯,我想知道他们的编码器实际上经过了多少测试... - MrGomez

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接