crypto/ssss: handle slightly broken key metadata better

This commit is contained in:
Tulir Asokan 2026-01-28 14:39:52 +02:00
commit 2c0d51ee7d
3 changed files with 62 additions and 23 deletions

View file

@ -59,12 +59,12 @@ func NewKey(passphrase string) (*Key, error) {
// We store a certain hash in the key metadata so that clients can check if the user entered the correct key.
ivBytes := random.Bytes(utils.AESCTRIVLength)
keyData.IV = base64.RawStdEncoding.EncodeToString(ivBytes)
var err error
keyData.MAC, err = keyData.calculateHash(ssssKey)
macBytes, err := keyData.calculateHash(ssssKey)
if err != nil {
// This should never happen because we just generated the IV and key.
return nil, fmt.Errorf("failed to calculate hash: %w", err)
}
keyData.MAC = base64.RawStdEncoding.EncodeToString(macBytes)
return &Key{
Key: ssssKey,

View file

@ -7,6 +7,8 @@
package ssss
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
@ -74,11 +76,16 @@ func (kd *KeyMetadata) verifyKey(key []byte) error {
if len(unpaddedMAC) != expectedMACLength {
return fmt.Errorf("%w: invalid mac length %d (expected %d)", ErrCorruptedKeyMetadata, len(unpaddedMAC), expectedMACLength)
}
hash, err := kd.calculateHash(key)
expectedMAC, err := base64.RawStdEncoding.DecodeString(unpaddedMAC)
if err != nil {
return fmt.Errorf("%w: failed to decode mac: %w", ErrCorruptedKeyMetadata, err)
}
calculatedMAC, err := kd.calculateHash(key)
if err != nil {
return err
}
if unpaddedMAC != hash {
// This doesn't really need to be constant time since it's fully local, but might as well be.
if !hmac.Equal(expectedMAC, calculatedMAC) {
return ErrIncorrectSSSSKey
}
return nil
@ -91,23 +98,26 @@ func (kd *KeyMetadata) VerifyKey(key []byte) bool {
// calculateHash calculates the hash used for checking if the key is entered correctly as described
// in the spec: https://matrix.org/docs/spec/client_server/unstable#m-secret-storage-v1-aes-hmac-sha2
func (kd *KeyMetadata) calculateHash(key []byte) (string, error) {
func (kd *KeyMetadata) calculateHash(key []byte) ([]byte, error) {
aesKey, hmacKey := utils.DeriveKeysSHA256(key, "")
unpaddedIV := strings.TrimRight(kd.IV, "=")
expectedIVLength := base64.RawStdEncoding.EncodedLen(utils.AESCTRIVLength)
if len(unpaddedIV) != expectedIVLength {
return "", fmt.Errorf("%w: invalid iv length %d (expected %d)", ErrCorruptedKeyMetadata, len(unpaddedIV), expectedIVLength)
if len(unpaddedIV) < expectedIVLength || len(unpaddedIV) > expectedIVLength*3 {
return nil, fmt.Errorf("%w: invalid iv length %d (expected %d)", ErrCorruptedKeyMetadata, len(unpaddedIV), expectedIVLength)
}
var ivBytes [utils.AESCTRIVLength]byte
_, err := base64.RawStdEncoding.Decode(ivBytes[:], []byte(unpaddedIV))
rawIVBytes, err := base64.RawStdEncoding.DecodeString(unpaddedIV)
if err != nil {
return "", fmt.Errorf("%w: failed to decode iv: %w", ErrCorruptedKeyMetadata, err)
return nil, fmt.Errorf("%w: failed to decode iv: %w", ErrCorruptedKeyMetadata, err)
}
// TODO log a warning for non-16 byte IVs?
// Certain broken clients like nheko generated 32-byte IVs where only the first 16 bytes were used.
ivBytes := *(*[utils.AESCTRIVLength]byte)(rawIVBytes[:utils.AESCTRIVLength])
cipher := utils.XorA256CTR(make([]byte, utils.AESCTRKeyLength), aesKey, ivBytes)
return utils.HMACSHA256B64(cipher, hmacKey), nil
zeroes := make([]byte, utils.AESCTRKeyLength)
encryptedZeroes := utils.XorA256CTR(zeroes, aesKey, ivBytes)
h := hmac.New(sha256.New, hmacKey[:])
h.Write(encryptedZeroes)
return h.Sum(nil), nil
}
// PassphraseMetadata represents server-side metadata about a SSSS key passphrase.

View file

@ -8,7 +8,6 @@ package ssss_test
import (
"encoding/json"
"errors"
"testing"
"github.com/stretchr/testify/assert"
@ -42,10 +41,24 @@ const key2Meta = `
}
`
const key2MetaUnverified = `
{
"algorithm": "m.secret_storage.v1.aes-hmac-sha2"
}
`
const key2MetaLongIV = `
{
"algorithm": "m.secret_storage.v1.aes-hmac-sha2",
"iv": "O0BOvTqiIAYjC+RMcyHfW2f/gdxjceTxoYtNlpPduJ8=",
"mac": "7k6OruQlWg0UmQjxGZ0ad4Q6DdwkgnoI7G6X3IjBYtI="
}
`
const key2MetaBrokenIV = `
{
"algorithm": "m.secret_storage.v1.aes-hmac-sha2",
"iv": "O0BOvTqiIAYjC+RMcyHfWwMeowMeowMeow",
"iv": "MeowMeowMeow",
"mac": "7k6OruQlWg0UmQjxGZ0ad4Q6DdwkgnoI7G6X3IjBYtI="
}
`
@ -94,17 +107,33 @@ func TestKeyMetadata_VerifyRecoveryKey_Correct2(t *testing.T) {
assert.Equal(t, key2RecoveryKey, key.RecoveryKey())
}
func TestKeyMetadata_VerifyRecoveryKey_NonCompliant_LongIV(t *testing.T) {
km := getKeyMeta(key2MetaLongIV)
key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey)
assert.NoError(t, err)
assert.NotNil(t, key)
assert.Equal(t, key2RecoveryKey, key.RecoveryKey())
}
func TestKeyMetadata_VerifyRecoveryKey_Unverified(t *testing.T) {
km := getKeyMeta(key2MetaUnverified)
key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey)
assert.ErrorIs(t, err, ssss.ErrUnverifiableKey)
assert.NotNil(t, key)
assert.Equal(t, key2RecoveryKey, key.RecoveryKey())
}
func TestKeyMetadata_VerifyRecoveryKey_Invalid(t *testing.T) {
km := getKeyMeta(key1Meta)
key, err := km.VerifyRecoveryKey(key1ID, "foo")
assert.True(t, errors.Is(err, ssss.ErrInvalidRecoveryKey), "unexpected error: %v", err)
assert.ErrorIs(t, err, ssss.ErrInvalidRecoveryKey)
assert.Nil(t, key)
}
func TestKeyMetadata_VerifyRecoveryKey_Incorrect(t *testing.T) {
km := getKeyMeta(key1Meta)
key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey)
assert.True(t, errors.Is(err, ssss.ErrIncorrectSSSSKey), "unexpected error: %v", err)
assert.ErrorIs(t, err, ssss.ErrIncorrectSSSSKey)
assert.Nil(t, key)
}
@ -119,27 +148,27 @@ func TestKeyMetadata_VerifyPassphrase_Correct(t *testing.T) {
func TestKeyMetadata_VerifyPassphrase_Incorrect(t *testing.T) {
km := getKeyMeta(key1Meta)
key, err := km.VerifyPassphrase(key1ID, "incorrect horse battery staple")
assert.True(t, errors.Is(err, ssss.ErrIncorrectSSSSKey), "unexpected error %v", err)
assert.ErrorIs(t, err, ssss.ErrIncorrectSSSSKey)
assert.Nil(t, key)
}
func TestKeyMetadata_VerifyPassphrase_NotSet(t *testing.T) {
km := getKeyMeta(key2Meta)
key, err := km.VerifyPassphrase(key2ID, "hmm")
assert.True(t, errors.Is(err, ssss.ErrNoPassphrase), "unexpected error %v", err)
assert.ErrorIs(t, err, ssss.ErrNoPassphrase)
assert.Nil(t, key)
}
func TestKeyMetadata_VerifyRecoveryKey_CorruptedIV(t *testing.T) {
km := getKeyMeta(key2MetaBrokenIV)
key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey)
assert.True(t, errors.Is(err, ssss.ErrCorruptedKeyMetadata), "unexpected error %v", err)
assert.ErrorIs(t, err, ssss.ErrCorruptedKeyMetadata)
assert.Nil(t, key)
}
func TestKeyMetadata_VerifyRecoveryKey_CorruptedMAC(t *testing.T) {
km := getKeyMeta(key2MetaBrokenMAC)
key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey)
assert.True(t, errors.Is(err, ssss.ErrCorruptedKeyMetadata), "unexpected error %v", err)
assert.ErrorIs(t, err, ssss.ErrCorruptedKeyMetadata)
assert.Nil(t, key)
}