Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Copyright 2026 libsignal-go contributors.
# SPDX-License-Identifier: AGPL-3.0-only
#
# compat-interop: builds the v0.96.4-pinned Rust harness and runs the live
# compat-interop: builds the v0.104.0-pinned Rust harness and runs the live
# JSON-RPC interop tests (compat/interop_test.go, build tag `interop`) against
# it, asserting the pure-Go implementation agrees with upstream on live
# operations. The committed-vector tests run in the `go` workflow with no Rust
Expand Down Expand Up @@ -55,7 +55,11 @@ jobs:

- name: build rust harness (release)
working-directory: compat/rust-harness
run: cargo build --release
run: cargo build --release --locked

- name: replay account-key oracle fixtures
working-directory: compat/rust-harness
run: cargo test --release --locked

- name: interop tests
env:
Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,19 @@ target, so that the interop gate is meaningful and reproducible.
v0.91.0** protocol surface — the last upstream release before the Sparse
Post-Quantum Ratchet (SPQR) was made mandatory for new sessions — while SPQR
was being ported (P5–P9 ran against this pin).
- **Stage 2 (current):** SPQR is ported (P10) and the compat harness is
re-pinned to **libsignal v0.96.4**, the current upstream mainline release. The
compatibility claim now covers the full current-mainline protocol surface,
- **Protocol baseline:** SPQR is ported and the compatibility baseline is
**libsignal v0.96.4**. The
compatibility claim covers that pinned protocol surface,
including SPQR-negotiated sessions (v0.96.4 requires SPQR — `min_version: V1`).
The committed vectors are byte-identical across the re-pin (the protos, the
`spqr` v1.5.1 pin, and `libcrux-ml-kem` 0.0.8 are unchanged from v0.91.0), and
the live Rust↔Go interop passes both roles with SPQR on the wire.
- **Current oracle:** the development-only Rust harness is pinned to the
immutable **libsignal v0.104.0** commit, with SPQR 1.6.0 and libcrux 0.0.10.
Account-key, PIN master-key, media-key, and MFA metadata vectors are refreshed
against this pin. The remaining committed vectors and global proof-report
baseline retain their original provenance until the full parity refresh;
changing the oracle dependency is not itself a full protocol upgrade.

The rationale, alternatives considered, and the exact pin boundary are recorded
in [`decisions/0001-spqr-staged-compat.md`](decisions/0001-spqr-staged-compat.md).
Expand Down Expand Up @@ -130,7 +136,7 @@ deliberate non-goals for this module.
| AES-256-GCM-SIV (RFC 8452) | ✅ implemented | [`internal/crypto/gcmsiv`](internal/crypto/gcmsiv/) | nonce-misuse-resistant AEAD for sealed sender v2 |
| Fingerprints (numeric + scannable) | ✅ implemented | [`fingerprint`](fingerprint/) | display + scannable byte-equal vs upstream |
| Sparse Post-Quantum Ratchet (SPQR) | ✅ implemented | [`spqr`](spqr/), [`internal/mlkem768incr`](internal/mlkem768incr/), [`internal/spqr/chunked`](internal/spqr/chunked/) | incremental ML-KEM-768 + GF(2^16) chunked transport + state machine, mixed into the session message keys; SPQR-negotiated interop both roles at v0.96.4 |
| Account keys (entropy pool, SVR key, PIN hash, backup key derivations) | ✅ implemented | [`accountkeys`](accountkeys/) | v0.96.4 known vectors for account entropy, backup ID, PIN hash, and local PIN PHC |
| Account keys (entropy pool, SVR/PIN, MFA metadata, backup derivations) | ✅ implemented | [`accountkeys`](accountkeys/) | v0.104.0 Rust oracle for SVR derivatives, PIN master-key SIV, fixed 160-byte MFA metadata, and media AES/HMAC splits; legacy entropy/backup/PIN vectors preserved |
| Username validation, candidates, username links, and reservation hash | ✅ implemented | [`usernames`](usernames/) | v0.96.4 username-link and reserve-hash vectors; proof generation deferred to zk/poksho phase |
| X3DH v3 session *initiation* | ⛔ excluded | — | v3 *decrypt*/state compat retained; v0.96.4 cannot initiate v3 |
| ML-KEM-1024 *activation* | ⛔ excluded | — | wire type `0x0A` parsing reserved only |
Expand Down Expand Up @@ -191,8 +197,14 @@ Runnable examples live alongside the packages they document (Go renders them in
distribution and a group-encrypted message.
- [`sealedsender.Example_sealedSender`](sealedsender/example_test.go) — a sealed
sender v1 message with certificate-chain validation.
- [`accountkeys`](accountkeys/) — account entropy, SVR key, PIN hash, and backup
key derivations.
- [`accountkeys`](accountkeys/) — account entropy, redacted `SVRKey`, registration
lock/recovery/storage/logging derivatives, authenticated PIN master-key
encoding, fixed-size encrypted MFA metadata, and backup/media key derivations.
MFA timestamps round down to whole seconds on the wire; names allow at most
98 UTF-8 bytes and no NUL. `Encrypt` requires a cryptographic random reader
such as `crypto/rand.Reader`. These are local cryptographic operations, not
registration, account recovery, or MFA enrollment APIs. Temporary buffers are
cleared on a best-effort basis; Go does not guarantee complete key zeroization.
- [`usernames`](usernames/) — username validation, candidate generation,
username links, and vector-backed reservation hashes via `Hash`,
`HashFromParts`, `HashHex`, and `CandidatesWithHashes`.
Expand Down
7 changes: 6 additions & 1 deletion accountkeys/accountkeys.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Package accountkeys implements Signal account-level key derivations.
//
// It is a pure-Go port of upstream libsignal rust/account-keys at v0.96.4.
// It is a pure-Go port of upstream libsignal rust/account-keys, including
// the SVR/PIN and MFA metadata additions verified against v0.104.0.
// The package deliberately contains no network or storage behavior.
package accountkeys

Expand Down Expand Up @@ -39,6 +40,10 @@ const (
BackupForwardSecrecyTokenLen = 32
// MediaEncryptionKeyLen is the length in bytes of backup media encryption key data.
MediaEncryptionKeyLen = 64
// MediaEncryptionAESKeyLen is the AES key prefix length in media encryption key data.
MediaEncryptionAESKeyLen = 32
// MediaEncryptionHMACKeyLen is the HMAC key suffix length in media encryption key data.
MediaEncryptionHMACKeyLen = 32
)

var (
Expand Down
209 changes: 209 additions & 0 deletions accountkeys/mfa_metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package accountkeys

import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/subtle"
"errors"
"fmt"
"io"
"math"
"strings"
"unicode/utf8"

"github.com/GoCodeAlone/libsignal-go/internal/accountkeysproto"
"google.golang.org/protobuf/encoding/protowire"
"google.golang.org/protobuf/proto"
)

const (
// MFAMetadataCiphertextLen is the fixed size of encrypted MFA key metadata.
MFAMetadataCiphertextLen = 160
// MFAKeyNameMaxLen is the maximum MFA key name length in UTF-8 bytes.
MFAKeyNameMaxLen = 98

mfaPaddedPlaintextLen = 112
mfaAuthenticatedLen = aes.BlockSize + mfaPaddedPlaintextLen
)

var errInvalidMFAMetadata = errors.New("invalid MFA metadata")

// MFAMetadata describes an MFA key without any service or account operations.
type MFAMetadata struct {
name string
createdAtEpochMillis uint64
}

// EncryptedMFAMetadata is the opaque, authenticated fixed-size MFA metadata blob.
type EncryptedMFAMetadata [MFAMetadataCiphertextLen]byte

// NewMFAMetadata validates metadata for an MFA key.
func NewMFAMetadata(name string, createdAtEpochMillis uint64) (MFAMetadata, error) {
if !utf8.ValidString(name) || strings.ContainsRune(name, 0) || len(name) > MFAKeyNameMaxLen {
return MFAMetadata{}, errors.New("invalid MFA key name")
}
return MFAMetadata{name: name, createdAtEpochMillis: createdAtEpochMillis}, nil
}

// Name returns the MFA key's name.
func (m MFAMetadata) Name() string { return m.name }

// CreatedAtEpochMillis returns the creation time in milliseconds.
func (m MFAMetadata) CreatedAtEpochMillis() uint64 { return m.createdAtEpochMillis }

// Encrypt authenticates and encrypts metadata using key and a cryptographic rng.
func (m MFAMetadata) Encrypt(key SVRKey, rng io.Reader) (EncryptedMFAMetadata, error) {
if _, err := NewMFAMetadata(m.name, m.createdAtEpochMillis); err != nil {
return EncryptedMFAMetadata{}, err
}
if rng == nil {
return EncryptedMFAMetadata{}, errors.New("MFA metadata requires a cryptographic random source")
}
plaintext, err := proto.Marshal(&accountkeysproto.MfaKeyMetadataPlaintext{
CreationEpochTimeSeconds: m.createdAtEpochMillis / 1000,
KeyName: m.name,
})
if err != nil {
return EncryptedMFAMetadata{}, errInvalidMFAMetadata
}
defer clear(plaintext)
if len(plaintext) >= mfaPaddedPlaintextLen {
return EncryptedMFAMetadata{}, errInvalidMFAMetadata
}
// Upstream pads to this fixed size, not to the next AES block boundary.
var padded [mfaPaddedPlaintextLen]byte
defer clear(padded[:])
copy(padded[:], plaintext)
padding := byte(mfaPaddedPlaintextLen - len(plaintext)) // #nosec G115 -- length guard bounds padding to 1..112.
for i := len(plaintext); i < len(padded); i++ {
padded[i] = padding
}
var out EncryptedMFAMetadata
if _, err := io.ReadFull(rng, out[:aes.BlockSize]); err != nil {
return EncryptedMFAMetadata{}, fmt.Errorf("MFA metadata randomness: %w", err)
}
cipherKey, authKey := key.mfaMetadataKeys()
defer clear(cipherKey[:])
defer clear(authKey[:])
block, err := aes.NewCipher(cipherKey[:])
if err != nil {
return EncryptedMFAMetadata{}, errInvalidMFAMetadata
}
cipher.NewCBCEncrypter(block, out[:aes.BlockSize]).CryptBlocks(out[aes.BlockSize:mfaAuthenticatedLen], padded[:])
mac := keyedHash(authKey[:], out[:mfaAuthenticatedLen])
copy(out[mfaAuthenticatedLen:], mac[:])
clear(mac[:])
return out, nil
}

// ParseEncryptedMFAMetadata copies a fixed-size ciphertext from raw.
func ParseEncryptedMFAMetadata(raw []byte) (EncryptedMFAMetadata, error) {
if len(raw) != MFAMetadataCiphertextLen {
return EncryptedMFAMetadata{}, errInvalidMFAMetadata
}
return EncryptedMFAMetadata(raw), nil
}

// Bytes returns a copy of the encrypted metadata's fixed-width representation.
func (m EncryptedMFAMetadata) Bytes() [MFAMetadataCiphertextLen]byte { return m }

// Decrypt authenticates and decrypts metadata, rejecting malformed plaintext.
func (m EncryptedMFAMetadata) Decrypt(key SVRKey) (MFAMetadata, error) {
cipherKey, authKey := key.mfaMetadataKeys()
defer clear(cipherKey[:])
defer clear(authKey[:])
mac := keyedHash(authKey[:], m[:mfaAuthenticatedLen])
defer clear(mac[:])
if !hmac.Equal(mac[:], m[mfaAuthenticatedLen:]) {
return MFAMetadata{}, errInvalidMFAMetadata
}
block, err := aes.NewCipher(cipherKey[:])
if err != nil {
return MFAMetadata{}, errInvalidMFAMetadata
}
var padded [mfaPaddedPlaintextLen]byte
defer clear(padded[:])
cipher.NewCBCDecrypter(block, m[:aes.BlockSize]).CryptBlocks(padded[:], m[aes.BlockSize:mfaAuthenticatedLen])
padding := int(padded[len(padded)-1])
if padding == 0 || padding > len(padded) {
return MFAMetadata{}, errInvalidMFAMetadata
}
valid := 1
for _, b := range padded[len(padded)-padding:] {
valid &= subtle.ConstantTimeByteEq(b, byte(padding))
}
if valid != 1 {
return MFAMetadata{}, errInvalidMFAMetadata
}
var plaintext accountkeysproto.MfaKeyMetadataPlaintext
encoded, validWire := mfaProtoPlaintext(padded[:len(padded)-padding])
defer clear(encoded)
if !validWire {
return MFAMetadata{}, errInvalidMFAMetadata
}
if err := proto.Unmarshal(encoded, &plaintext); err != nil {
return MFAMetadata{}, errInvalidMFAMetadata
}
if plaintext.CreationEpochTimeSeconds > math.MaxUint64/1000 {
return MFAMetadata{}, errInvalidMFAMetadata
}
metadata, err := NewMFAMetadata(plaintext.KeyName, plaintext.CreationEpochTimeSeconds*1000)
if err != nil {
return MFAMetadata{}, errInvalidMFAMetadata
}
return metadata, nil
}

func (k SVRKey) mfaMetadataKeys() (cipherKey, authKey [32]byte) {
return keyedHash(k.bytes[:], []byte("20260831 TOTP Metadata Encryption")),
keyedHash(k.bytes[:], []byte("20260831 TOTP Metadata Authentication"))
}

// Rust protobuf 3.7 decodes tags and byte lengths as five-byte u32 varints;
// Go accepts longer encodings. Check those bounds without replacing generated
// decoding. Rust discards unknown groups, accepting any valid end-group tag;
// omit those groups here while retaining ordinary unknown fields.
func mfaProtoPlaintext(raw []byte) ([]byte, bool) {
encoded := make([]byte, 0, len(raw))
groups := 0
for len(raw) > 0 {
field := raw
tag, n := protowire.ConsumeVarint(raw)
if n < 0 || n > 5 || tag > math.MaxUint32 {
return encoded, false
}
num, typ := protowire.DecodeTag(tag)
if !num.IsValid() {
return encoded, false
}
raw = raw[n:]
switch typ {
case protowire.StartGroupType:
groups++
continue
case protowire.EndGroupType:
if groups == 0 {
return encoded, false
}
groups--
continue
case protowire.BytesType:
length, prefix := protowire.ConsumeVarint(raw)
if prefix < 0 || prefix > 5 || length > math.MaxUint32 || length > uint64(len(raw)-prefix) {
return encoded, false
}
n = prefix + int(length) // #nosec G115 -- length is bounded by the remaining 111-byte plaintext.
default:
n = protowire.ConsumeFieldValue(num, typ, raw)
if n < 0 {
return encoded, false
}
}
if groups == 0 {
encoded = append(encoded, field[:len(field)-len(raw)+n]...)
}
raw = raw[n:]
}
return encoded, groups == 0
}
Loading
Loading