Add project files.

This commit is contained in:
micro
2026-06-20 09:31:59 -05:00
parent 9bb3f00d86
commit 4baa4ce8c0
346 changed files with 55751 additions and 0 deletions
@@ -0,0 +1,167 @@
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Wingnal.Protocol.ZkGroup.Curve;
using Wingnal.Protocol.ZkGroup.ZkCredential;
namespace Wingnal.Protocol.ZkGroup;
/// <summary>
/// Port of zkgroup's <c>AuthCredentialWithPniZkc</c> — the authentication credential a client receives from
/// the chat server and presents (anonymously) to the storage service to act on a group. The presentation
/// proves possession of a valid credential over (aci, pni, redemptionTime) while only revealing the aci/pni
/// encrypted under the group's UID key. Built on the generic zkcredential issuance/presentation system.
/// </summary>
public sealed class AuthCredentialWithPni
{
public const int PresentationVersion4 = 3;
private static readonly byte[] Label = Encoding.ASCII.GetBytes("20240222_Signal_AuthCredentialZkc");
public Credential Credential { get; }
public UidStruct Aci { get; }
public UidStruct Pni { get; }
public ulong RedemptionTime { get; }
private AuthCredentialWithPni(Credential credential, UidStruct aci, UidStruct pni, ulong redemptionTime)
{
Credential = credential; Aci = aci; Pni = pni; RedemptionTime = redemptionTime;
}
private const byte VersionZkc = 3; // AuthCredentialWithPniVersion::Zkc
// ── server side (offline tests): issue ──
public static IssuanceProof Issue(ServiceId aci, ServiceId pni, ulong redemptionTime,
CredentialKeyPair credentialKey, byte[] randomness)
{
return new IssuanceProofBuilder(Label)
.AddAttribute(UidStruct.FromServiceId(aci).AsPoints())
.AddAttribute(UidStruct.FromServiceId(pni).AsPoints())
.AddPublicAttributeU64(redemptionTime)
.Issue(credentialKey, randomness);
}
/// <summary>The serialized AuthCredentialWithPniResponse the chat server returns: version(3) ‖ IssuanceProof.</summary>
public static byte[] IssueResponse(ServiceId aci, ServiceId pni, ulong redemptionTime,
CredentialKeyPair credentialKey, byte[] randomness)
{
var b = new List<byte> { VersionZkc };
b.AddRange(Issue(aci, pni, redemptionTime, credentialKey, randomness).Serialize());
return b.ToArray();
}
// ── client side: receive a credential ──
/// <summary>Receives the chat server's serialized AuthCredentialWithPniResponse using Signal's published
/// credential public key (<see cref="ServerPublicParams.Production"/>).</summary>
public static AuthCredentialWithPni ReceiveResponse(byte[] responseBytes, ServiceId aci, ServiceId pni,
ulong redemptionTime, CredentialPublicKey? credentialPublicKey = null)
{
if (responseBytes.Length < 1 || responseBytes[0] != VersionZkc)
throw new ZkGroupVerificationException("bad AuthCredentialWithPniResponse version");
IssuanceProof proof = IssuanceProof.Deserialize(responseBytes.AsSpan(1));
return Receive(proof, aci, pni, redemptionTime,
credentialPublicKey ?? ServerPublicParams.Production.GenericCredentialPublicKey);
}
public static AuthCredentialWithPni Receive(IssuanceProof proof, ServiceId aci, ServiceId pni,
ulong redemptionTime, CredentialPublicKey credentialPublicKey)
{
if (redemptionTime % 86400 != 0)
throw new ZkGroupVerificationException("redemption time not day-aligned");
UidStruct aciStruct = UidStruct.FromServiceId(aci);
UidStruct pniStruct = UidStruct.FromServiceId(pni);
Credential credential = new IssuanceProofBuilder(Label)
.AddAttribute(aciStruct.AsPoints())
.AddAttribute(pniStruct.AsPoints())
.AddPublicAttributeU64(redemptionTime)
.Verify(credentialPublicKey, proof);
return new AuthCredentialWithPni(credential, aciStruct, pniStruct, redemptionTime);
}
// ── client side: build the presentation for a group ──
public byte[] Present(CredentialPublicKey credentialPublicKey, GroupSecretParams group, byte[] randomness)
{
EncryptionKeyContext uidKey = UidKeyContext(group);
PresentationProof proof = new PresentationProofBuilder(Label)
.AddAttribute(Aci.AsPoints(), uidKey)
.AddAttribute(Pni.AsPoints(), uidKey)
.Present(credentialPublicKey, Credential, randomness);
AttributeCiphertext aciCt = UidEncryption.Encrypt(group.UidKeyPair, Aci);
AttributeCiphertext pniCt = UidEncryption.Encrypt(group.UidKeyPair, Pni);
var b = new List<byte> { PresentationVersion4 };
b.AddRange(proof.Serialize());
b.AddRange(aciCt.Serialize()); // 64 bytes (no reserved byte at this layer)
b.AddRange(pniCt.Serialize()); // 64 bytes
Span<byte> rt = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(rt, RedemptionTime);
b.AddRange(rt.ToArray());
return b.ToArray();
}
internal static EncryptionKeyContext UidKeyContext(GroupSecretParams group) => new()
{
Id = UidEncryption.DomainId,
Ga1 = UidEncryption.SystemParams.Ga1,
Ga2 = UidEncryption.SystemParams.Ga2,
A1 = group.UidKeyPair.A1,
A2 = group.UidKeyPair.A2,
PublicKeyA = group.UidKeyPair.PublicKey,
};
// ── verifying-server side (offline tests) ──
/// <summary>Verifies a serialized presentation against the server's credential key and the group's
/// public UID key. Returns the embedded (aci, pni) ciphertexts on success.</summary>
public static (UuidCiphertext aci, UuidCiphertext pni) VerifyPresentation(
byte[] presentation, CredentialKeyPair credentialKey, Ristretto255 groupUidPublicKey, ulong redemptionTime)
{
int o = 0;
if (presentation.Length < 1 || presentation[0] != PresentationVersion4)
throw new ZkGroupVerificationException("bad presentation version");
o = 1;
var proof = new PresentationProof
{
Cx0 = ReadPoint(presentation, ref o),
Cx1 = ReadPoint(presentation, ref o),
Cv = ReadPoint(presentation, ref o),
};
ulong cyLen = BinaryPrimitives.ReadUInt64LittleEndian(presentation.AsSpan(o, 8)); o += 8;
var cy = new Ristretto255[cyLen];
for (ulong i = 0; i < cyLen; i++) cy[i] = ReadPoint(presentation, ref o);
proof.Cy = cy;
ulong proofLen = BinaryPrimitives.ReadUInt64LittleEndian(presentation.AsSpan(o, 8)); o += 8;
proof.PokshoProof = presentation.AsSpan(o, (int)proofLen).ToArray(); o += (int)proofLen;
var aciCt = AttributeCiphertext.Deserialize(presentation.AsSpan(o, 64)); o += 64;
var pniCt = AttributeCiphertext.Deserialize(presentation.AsSpan(o, 64)); o += 64;
ulong embeddedRedemption = BinaryPrimitives.ReadUInt64LittleEndian(presentation.AsSpan(o, 8)); o += 8;
if (embeddedRedemption != redemptionTime)
throw new ZkGroupVerificationException("redemption time mismatch");
var pubKey = new EncryptionKeyContext
{
Id = UidEncryption.DomainId,
Ga1 = UidEncryption.SystemParams.Ga1,
Ga2 = UidEncryption.SystemParams.Ga2,
PublicKeyA = groupUidPublicKey,
};
bool ok = new PresentationProofVerifier(Label)
.AddAttribute(aciCt.AsPoints(), pubKey)
.AddAttribute(pniCt.AsPoints(), pubKey)
.AddPublicAttributeU64(redemptionTime)
.Verify(credentialKey, proof);
if (!ok) throw new ZkGroupVerificationException("presentation proof did not verify");
return (new UuidCiphertext(aciCt), new UuidCiphertext(pniCt));
}
private static Ristretto255 ReadPoint(byte[] b, ref int o)
{
Ristretto255 p = Ristretto255.Decode(b.AsSpan(o, 32)) ?? throw new ZkGroupVerificationException("bad point");
o += 32;
return p;
}
}
+44
View File
@@ -0,0 +1,44 @@
using Wingnal.Protocol.ZkGroup.ZkCredential;
namespace Wingnal.Protocol.ZkGroup;
/// <summary>A group member's service id, encrypted under the group's UID key. Wire form is a reserved 0x00
/// byte followed by the 64-byte <see cref="AttributeCiphertext"/> (65 bytes total), matching zkgroup.</summary>
public readonly struct UuidCiphertext
{
public readonly AttributeCiphertext Ciphertext;
public UuidCiphertext(AttributeCiphertext ct) => Ciphertext = ct;
public byte[] Serialize()
{
var b = new byte[65];
Array.Copy(Ciphertext.Serialize(), 0, b, 1, 64); // b[0] = reserved 0x00
return b;
}
public static UuidCiphertext Deserialize(ReadOnlySpan<byte> bytes65)
{
if (bytes65.Length != 65 || bytes65[0] != 0) throw new ArgumentException("bad UuidCiphertext");
return new UuidCiphertext(AttributeCiphertext.Deserialize(bytes65[1..]));
}
}
/// <summary>A group member's profile key, encrypted under the group's profile-key key (reserved 0x00 ‖ 64).</summary>
public readonly struct ProfileKeyCiphertext
{
public readonly AttributeCiphertext Ciphertext;
public ProfileKeyCiphertext(AttributeCiphertext ct) => Ciphertext = ct;
public byte[] Serialize()
{
var b = new byte[65];
Array.Copy(Ciphertext.Serialize(), 0, b, 1, 64);
return b;
}
public static ProfileKeyCiphertext Deserialize(ReadOnlySpan<byte> bytes65)
{
if (bytes65.Length != 65 || bytes65[0] != 0) throw new ArgumentException("bad ProfileKeyCiphertext");
return new ProfileKeyCiphertext(AttributeCiphertext.Deserialize(bytes65[1..]));
}
}
+91
View File
@@ -0,0 +1,91 @@
using Org.BouncyCastle.Math.EC.Rfc7748;
namespace Wingnal.Protocol.ZkGroup.Curve;
/// <summary>
/// A field element of GF(2²⁵⁵−19), wrapping BouncyCastle's vetted constant-time <see cref="X25519Field"/>
/// representation (10 limbs) with value-semantics helpers (each op returns a fresh element, so BC's
/// not-alias-safe Mul/Sqr are always called with distinct outputs). Every returned element is carried, so
/// it is safe to feed straight into another Mul/Sqr. This is the base field for the Ristretto255 group
/// hand-port (zkgroup) — see <see cref="Ristretto255"/>.
/// </summary>
internal sealed class Fe
{
internal readonly int[] L; // 10-limb X25519Field representation
private Fe(int[] l) => L = l;
public static Fe Zero() { var f = new Fe(X25519Field.Create()); X25519Field.Zero(f.L); return f; }
public static Fe One() { var f = new Fe(X25519Field.Create()); X25519Field.One(f.L); return f; }
/// <summary>Decodes 32 little-endian bytes, masking bit 255 (dalek <c>FieldElement::from_bytes</c>
/// semantics). The value is reduced mod p on the next Normalize/Encode.</summary>
public static Fe Decode(ReadOnlySpan<byte> bytes32)
{
Span<byte> b = stackalloc byte[32];
bytes32[..32].CopyTo(b);
b[31] &= 0x7f;
var f = new Fe(X25519Field.Create());
X25519Field.Decode(b.ToArray(), 0, f.L);
return f;
}
/// <summary>Canonical 32-byte little-endian encoding (reduced mod p; bit 255 = 0).</summary>
public byte[] Encode()
{
int[] t = (int[])L.Clone();
X25519Field.Normalize(t);
var b = new byte[32];
X25519Field.Encode(t, b, 0);
return b;
}
public Fe Clone() => new((int[])L.Clone());
public static Fe Add(Fe a, Fe b) { var r = new Fe(X25519Field.Create()); X25519Field.Add(a.L, b.L, r.L); X25519Field.Carry(r.L); return r; }
public static Fe Sub(Fe a, Fe b) { var r = new Fe(X25519Field.Create()); X25519Field.Sub(a.L, b.L, r.L); X25519Field.Carry(r.L); return r; }
public static Fe Mul(Fe a, Fe b) { var r = new Fe(X25519Field.Create()); X25519Field.Mul(a.L, b.L, r.L); return r; }
public static Fe Sqr(Fe a) { var r = new Fe(X25519Field.Create()); X25519Field.Sqr(a.L, r.L); return r; }
public static Fe Inv(Fe a) { var r = new Fe(X25519Field.Create()); X25519Field.Inv(a.L, r.L); return r; }
public static Fe Neg(Fe a) { var r = a.Clone(); X25519Field.CNegate(1, r.L); X25519Field.Carry(r.L); return r; }
private static Fe SqrN(Fe x, int n) { Fe r = x; for (int i = 0; i < n; i++) r = Sqr(r); return r; }
/// <summary>x^((p5)/8) = x^(2²⁵²−3), the inverse-fourth-root exponent used by sqrt_ratio. ref10's
/// pow22523 addition chain (BC's equivalent <c>X25519Field.PowPm5d8</c> is internal).</summary>
public static Fe PowP58(Fe z)
{
Fe t0 = Sqr(z); // z^2
Fe t1 = Sqr(Sqr(t0)); // z^8
t1 = Mul(z, t1); // z^9
t0 = Mul(t0, t1); // z^11
t0 = Sqr(t0); // z^22
t0 = Mul(t1, t0); // z^(2^5-1)
t1 = SqrN(t0, 5); t0 = Mul(t1, t0); // 2^10-1
t1 = SqrN(t0, 10); t1 = Mul(t1, t0); // 2^20-1
Fe t2 = SqrN(t1, 20); t1 = Mul(t2, t1); // 2^40-1
t1 = SqrN(t1, 10); t0 = Mul(t1, t0); // 2^50-1
t1 = SqrN(t0, 50); t1 = Mul(t1, t0); // 2^100-1
t2 = SqrN(t1, 100); t1 = Mul(t2, t1); // 2^200-1
t1 = SqrN(t1, 50); t0 = Mul(t1, t0); // 2^250-1
t0 = Sqr(Sqr(t0)); // 2^252-4
return Mul(t0, z); // 2^252-3
}
/// <summary>cond ? b : a (constant-time select).</summary>
public static Fe Select(Fe a, Fe b, bool cond)
{
var r = a.Clone();
X25519Field.CMov(cond ? -1 : 0, b.L, 0, r.L, 0);
return r;
}
public bool IsNegative() => (Encode()[0] & 1) == 1;
public bool IsZero() => Equals(Zero());
public bool ConstantTimeEquals(Fe other) => Encode().AsSpan().SequenceEqual(other.Encode());
public bool Equals(Fe other) => ConstantTimeEquals(other);
/// <summary>|x| = (x is negative) ? x : x.</summary>
public Fe Abs() => IsNegative() ? Neg(this) : Clone();
}
+52
View File
@@ -0,0 +1,52 @@
using System.Security.Cryptography;
namespace Wingnal.Protocol.ZkGroup.Curve;
/// <summary>
/// The "Lizard" encoding from the curve25519-dalek-<b>signal</b> fork (NOT RFC 9496, NOT upstream dalek):
/// reversibly maps 16 bytes (a raw UUID) to a Ristretto255 point and back. zkgroup uses it to put a
/// member's ACI/PNI inside a homomorphically-encryptable group element (see <c>UidStruct.M2</c>).
///
/// Encode: <c>fe = SHA-256(data) with bytes[8..24] overwritten by data, low bit and top two bits cleared;
/// point = ElligatorRistrettoFlavor(fe)</c>. Decode inverts Elligator (up to 8 candidate field elements via
/// the Jacobi quartic) and keeps the unique one whose embedded bytes re-hash to itself.
///
/// Validated against the dalek-signal lizard test vectors (encode) + round-trip (decode).
/// NOT constant-time (data-dependent branching in decode) — acceptable for client-side group decryption.
/// </summary>
public static class Lizard
{
/// <summary>Encodes 16 bytes to a Ristretto255 point.</summary>
public static Ristretto255 Encode(ReadOnlySpan<byte> data16)
{
if (data16.Length != 16) throw new ArgumentException("Lizard.Encode expects 16 bytes");
Span<byte> feBytes = stackalloc byte[32];
SHA256.HashData(data16, feBytes);
data16.CopyTo(feBytes[8..24]);
feBytes[0] &= 254; // make positive — Elligator on r and -r is the same
feBytes[31] &= 63; // < 2²⁵⁴
return Ristretto255.FromSingleElligatorBytes(feBytes);
}
/// <summary>Recovers the 16 bytes from a Lizard-encoded point, or null if it isn't a valid encoding.</summary>
public static byte[]? Decode(Ristretto255 p)
{
(byte mask, Fe[] fes) = p.ElligatorInverse();
byte[]? result = null;
int found = 0;
Span<byte> recomputed = stackalloc byte[32];
for (int j = 0; j < 8; j++)
{
if (((mask >> j) & 1) == 0) continue;
byte[] buf = fes[j].Encode(); // 32-byte canonical encoding
SHA256.HashData(buf.AsSpan(8, 16), recomputed);
buf.AsSpan(8, 16).CopyTo(recomputed[8..24]);
recomputed[0] &= 254;
recomputed[31] &= 63;
if (!recomputed.SequenceEqual(buf)) continue;
result = buf[8..24];
found++;
}
return found == 1 ? result : null;
}
}
@@ -0,0 +1,304 @@
namespace Wingnal.Protocol.ZkGroup.Curve;
/// <summary>
/// Hand-port of the Ristretto255 prime-order group (RFC 9496) on top of <see cref="Fe"/> / BouncyCastle's
/// edwards25519 base field — BouncyCastle 2.5.1 has no Ristretto, and zkgroup is built entirely on this
/// group. Provides point add / scalar-mul, the canonical 32-byte encode/decode, and the one-way map
/// <see cref="FromUniformBytes"/> (Elligator) used for hash-to-group. Correctness is gated by the RFC 9496
/// Appendix-A test vectors (multiples of the generator, invalid-encoding rejection, hash-to-group).
///
/// NOTE: scalar multiplication here is NOT constant-time (double-and-add with a data-dependent add). For
/// the client-side zkgroup proofs this is acceptable for a first correct version; harden later. See
/// docs/GROUPS.md / SHORTCUTS.md.
/// </summary>
public sealed class Ristretto255
{
// ── field constants (computed from definitions; SQRT_M1 hardcoded + self-checked in tests) ──
/// <summary>sqrt(-1) mod p, COMPUTED (not transcribed): p ≡ 5 (mod 8) ⇒ 2 is a non-residue, so
/// 2^((p-1)/4) is a square root of -1, and (p-1)/4 = 2·(p-5)/8 + 1 ⇒ SQRT_M1 = 2·(2^((p-5)/8))².
/// Self-checked SQRT_M1² == -1 by the Phase B vector test.</summary>
internal static readonly Fe SqrtM1 = BuildSqrtM1();
private static Fe BuildSqrtM1()
{
Fe two = Fe.Add(Fe.One(), Fe.One());
return Fe.Mul(Fe.Sqr(Fe.PowP58(two)), two);
}
internal static readonly Fe D = BuildD(); // edwards25519 d = -121665/121666
private static readonly Fe D2 = Fe.Add(D, D); // 2d, for the addition formula
private static readonly Fe OneMinusDSq = Fe.Sub(Fe.One(), Fe.Sqr(D)); // 1 - d²
private static readonly Fe DMinusOneSq = Fe.Sqr(Fe.Sub(D, Fe.One())); // (d - 1)²
// a = -1, so a - d = a*d - 1 = -1 - d.
private static readonly Fe AMinusD = Fe.Sub(Fe.Neg(Fe.One()), D); // -1 - d
// 1/sqrt(-1-d): the abs (even) root — matches dalek INVSQRT_A_MINUS_D (even).
internal static readonly Fe InvSqrtAMinusD = SqrtRatioM1(Fe.One(), AMinusD).root;
// sqrt(-1-d): dalek SQRT_AD_MINUS_ONE is the odd root, so negate the abs (even) root.
private static readonly Fe SqrtADMinusOne = Fe.Neg(SqrtRatioM1(AMinusD, Fe.One()).root);
// ── Lizard constants (computed from the definitions in the dalek-signal lizard_constants test) ──
// SQRT_ID = sqrt(i·d) (abs root); DP1_OVER_DM1 = (d+1)/(d-1);
// MDOUBLE_INVSQRT_A_MINUS_D = -2/sqrt(a-d); MIDOUBLE = that·i; MINVSQRT_ONE_PLUS_D = -1/sqrt(1+d).
private static readonly Fe SqrtId = SqrtRatioM1(Fe.Mul(SqrtM1, D), Fe.One()).root;
private static readonly Fe Dp1OverDm1 = Fe.Mul(Fe.Add(D, Fe.One()), Fe.Inv(Fe.Sub(D, Fe.One())));
private static readonly Fe MDoubleInvSqrtAMinusD = Fe.Neg(Fe.Add(InvSqrtAMinusD, InvSqrtAMinusD));
private static readonly Fe MiDoubleInvSqrtAMinusD = Fe.Mul(MDoubleInvSqrtAMinusD, SqrtM1);
private static readonly Fe MInvSqrtOnePlusD = Fe.Neg(SqrtRatioM1(Fe.One(), Fe.Add(D, Fe.One())).root);
private static Fe BuildD()
{
// d = -121665/121666 (computed, not transcribed).
var num = new byte[32]; num[0] = 0x41; num[1] = 0xDB; num[2] = 0x01; // 121665 = 0x1DB41
var den = new byte[32]; den[0] = 0x42; den[1] = 0xDB; den[2] = 0x01; // 121666 = 0x1DB42
return Fe.Neg(Fe.Mul(Fe.Decode(num), Fe.Inv(Fe.Decode(den))));
}
// ── point (extended twisted-Edwards coordinates X:Y:Z:T) ──
private readonly Fe _x, _y, _z, _t;
private Ristretto255(Fe x, Fe y, Fe z, Fe t) { _x = x; _y = y; _z = z; _t = t; }
/// <summary>The identity element.</summary>
public static Ristretto255 Identity => new(Fe.Zero(), Fe.One(), Fe.One(), Fe.Zero());
/// <summary>The Ristretto255 generator (canonical encoding e2f2ae0a…).</summary>
public static Ristretto255 BasePoint => Decode(
Convert.FromHexString("e2f2ae0a6abc4e71a884a961c500515f58e30b6aa582dd8db6a65945e08d2d76"))!;
/// <summary>Group addition (the complete a=-1 twisted-Edwards formula; also valid for doubling).</summary>
public static Ristretto255 Add(Ristretto255 p, Ristretto255 q)
{
Fe a = Fe.Mul(Fe.Sub(p._y, p._x), Fe.Sub(q._y, q._x)); // (Y1-X1)(Y2-X2)
Fe b = Fe.Mul(Fe.Add(p._y, p._x), Fe.Add(q._y, q._x)); // (Y1+X1)(Y2+X2)
Fe c = Fe.Mul(Fe.Mul(p._t, q._t), D2); // 2d·T1·T2
Fe d = Fe.Add(Fe.Mul(p._z, q._z), Fe.Mul(p._z, q._z)); // 2·Z1·Z2
Fe e = Fe.Sub(b, a), f = Fe.Sub(d, c), g = Fe.Add(d, c), h = Fe.Add(b, a);
return new Ristretto255(Fe.Mul(e, f), Fe.Mul(g, h), Fe.Mul(f, g), Fe.Mul(e, h));
}
/// <summary>Group negation: (X:Y:Z:T) = (X:Y:Z:T).</summary>
public static Ristretto255 Negate(Ristretto255 p) => new(Fe.Neg(p._x), p._y, p._z, Fe.Neg(p._t));
/// <summary>scalar·this.</summary>
public Ristretto255 Multiply(Scalar25519 s) => Multiply(s.ToBytes());
/// <summary>scalar·this (double-and-add, MSB first; scalar is 32-byte little-endian).</summary>
public Ristretto255 Multiply(ReadOnlySpan<byte> scalarLe)
{
Ristretto255 r = Identity;
for (int i = 255; i >= 0; i--)
{
r = Add(r, r);
if (((scalarLe[i >> 3] >> (i & 7)) & 1) == 1) r = Add(r, this);
}
return r;
}
/// <summary>Ristretto equality: two representatives are equal iff X1·Y2 == Y1·X2 and Y1·Y2 == X1·X2
/// (RFC 9496 §4.3.6). Cheaper + sign-robust vs comparing encodings.</summary>
public bool ConstantTimeEquals(Ristretto255 q)
{
bool a = Fe.Mul(_x, q._y).ConstantTimeEquals(Fe.Mul(_y, q._x));
bool b = Fe.Mul(_y, q._y).ConstantTimeEquals(Fe.Mul(_x, q._x));
return a || b;
}
// ── encode / decode (RFC 9496 §4.3.14.3.2) ──
public byte[] Encode()
{
Fe u1 = Fe.Mul(Fe.Add(_z, _y), Fe.Sub(_z, _y)); // (Z+Y)(Z-Y)
Fe u2 = Fe.Mul(_x, _y);
(_, Fe invsqrt) = SqrtRatioM1(Fe.One(), Fe.Mul(u1, Fe.Sqr(u2)));
Fe den1 = Fe.Mul(invsqrt, u1);
Fe den2 = Fe.Mul(invsqrt, u2);
Fe zInv = Fe.Mul(Fe.Mul(den1, den2), _t);
Fe ix = Fe.Mul(_x, SqrtM1);
Fe iy = Fe.Mul(_y, SqrtM1);
Fe enchantedDenominator = Fe.Mul(den1, InvSqrtAMinusD);
bool rotate = Fe.Mul(_t, zInv).IsNegative();
Fe x = Fe.Select(_x, iy, rotate);
Fe y = Fe.Select(_y, ix, rotate);
Fe denInv = Fe.Select(den2, enchantedDenominator, rotate);
y = Fe.Select(y, Fe.Neg(y), Fe.Mul(x, zInv).IsNegative());
Fe s = Fe.Mul(denInv, Fe.Sub(_z, y)).Abs();
return s.Encode();
}
public static Ristretto255? Decode(ReadOnlySpan<byte> bytes32)
{
if (bytes32.Length != 32) return null;
Fe s = Fe.Decode(bytes32);
// s must be the canonical encoding of a non-negative field element.
if (!s.Encode().AsSpan().SequenceEqual(bytes32) || s.IsNegative()) return null;
Fe ss = Fe.Sqr(s);
Fe u1 = Fe.Sub(Fe.One(), ss); // 1 - s²
Fe u2 = Fe.Add(Fe.One(), ss); // 1 + s²
Fe u2Sqr = Fe.Sqr(u2);
Fe v = Fe.Sub(Fe.Neg(Fe.Mul(D, Fe.Sqr(u1))), u2Sqr); // -(d·u1²) - u2²
(bool wasSquare, Fe invsqrt) = SqrtRatioM1(Fe.One(), Fe.Mul(v, u2Sqr));
Fe denX = Fe.Mul(invsqrt, u2);
Fe denY = Fe.Mul(Fe.Mul(invsqrt, denX), v);
Fe x = Fe.Mul(Fe.Add(s, s), denX).Abs(); // |2·s·den_x|
Fe y = Fe.Mul(u1, denY);
Fe t = Fe.Mul(x, y);
if (!wasSquare || t.IsNegative() || y.IsZero()) return null;
return new Ristretto255(x, y, Fe.One(), t);
}
// ── hash-to-group (RFC 9496 §4.3.4) ──
/// <summary>Maps 64 uniformly-random bytes to a group element (two Elligator maps + add).</summary>
public static Ristretto255 FromUniformBytes(ReadOnlySpan<byte> bytes64)
{
Ristretto255 p1 = ElligatorRistrettoFlavor(Fe.Decode(bytes64[..32]));
Ristretto255 p2 = ElligatorRistrettoFlavor(Fe.Decode(bytes64[32..64]));
return Add(p1, p2);
}
/// <summary>Maps a single 32-byte field element to a group element (one Elligator map). This is
/// dalek-signal's <c>from_uniform_bytes_single_elligator</c> / zkgroup's <c>get_point_single_elligator</c>,
/// and the encode half of Lizard.</summary>
public static Ristretto255 FromSingleElligatorBytes(ReadOnlySpan<byte> bytes32) =>
ElligatorRistrettoFlavor(Fe.Decode(bytes32));
/// <summary>The Ristretto-flavored Elligator2 map (RFC 9496 §4.3.4 MAP). Public so Lizard can reuse it.</summary>
internal static Ristretto255 ElligatorRistrettoFlavor(Fe t)
{
Fe r = Fe.Mul(SqrtM1, Fe.Sqr(t));
Fe u = Fe.Mul(Fe.Add(r, Fe.One()), OneMinusDSq);
Fe c = Fe.Neg(Fe.One());
Fe v = Fe.Mul(Fe.Sub(c, Fe.Mul(r, D)), Fe.Add(r, D));
(bool wasSquare, Fe s) = SqrtRatioM1(u, v);
Fe sPrime = Fe.Neg(Fe.Mul(s, t).Abs());
s = Fe.Select(sPrime, s, wasSquare);
c = Fe.Select(r, c, wasSquare);
Fe n = Fe.Sub(Fe.Mul(Fe.Mul(c, Fe.Sub(r, Fe.One())), DMinusOneSq), v);
Fe w0 = Fe.Add(Fe.Mul(s, v), Fe.Mul(s, v)); // 2·s·v
Fe w1 = Fe.Mul(n, SqrtADMinusOne);
Fe w2 = Fe.Sub(Fe.One(), Fe.Sqr(s));
Fe w3 = Fe.Add(Fe.One(), Fe.Sqr(s));
return new Ristretto255(Fe.Mul(w0, w3), Fe.Mul(w2, w1), Fe.Mul(w1, w3), Fe.Mul(w0, w2));
}
// ── sqrt_ratio_i (RFC 9496 §4.3) : returns (wasSquare, |sqrt(u/v)|) ──
internal static (bool wasSquare, Fe root) SqrtRatioM1(Fe u, Fe v)
{
Fe v3 = Fe.Mul(Fe.Sqr(v), v);
Fe v7 = Fe.Mul(Fe.Sqr(v3), v);
Fe r = Fe.Mul(Fe.Mul(u, v3), Fe.PowP58(Fe.Mul(u, v7)));
Fe check = Fe.Mul(v, Fe.Sqr(r));
Fe uNeg = Fe.Neg(u);
bool correct = check.ConstantTimeEquals(u);
bool flipped = check.ConstantTimeEquals(uNeg);
bool flippedI = check.ConstantTimeEquals(Fe.Mul(uNeg, SqrtM1));
Fe rPrime = Fe.Mul(SqrtM1, r);
r = Fe.Select(r, rPrime, flipped || flippedI);
return (correct || flipped, r.Abs());
}
// ── Elligator inverse (for Lizard decode) — port of the dalek-signal lizard fork ──
private readonly struct JacobiPoint
{
public readonly Fe S, T;
public JacobiPoint(Fe s, Fe t) { S = s; T = t; }
public JacobiPoint Dual() => new(Fe.Neg(S), Fe.Neg(T));
/// <summary>Computes the field element that Elligator2 maps to this Jacobi-quartic point, if any.</summary>
public (bool ok, Fe fe) ElligatorInv()
{
Fe outFe = Fe.Zero();
bool sIsZero = S.IsZero();
bool tEqualsOne = T.ConstantTimeEquals(Fe.One());
outFe = Fe.Select(outFe, SqrtId, tEqualsOne);
bool ret = sIsZero;
bool done = sIsZero;
Fe a = Fe.Mul(Fe.Add(T, Fe.One()), Dp1OverDm1);
Fe a2 = Fe.Sqr(a);
Fe s2 = Fe.Sqr(S);
Fe s4 = Fe.Sqr(s2);
Fe invSqY = Fe.Mul(Fe.Sub(s4, a2), SqrtM1);
(bool sq, Fe y) = SqrtRatioM1(Fe.One(), invSqY); // invsqrt
ret = ret || sq;
done = done || !sq;
Fe pms2 = Fe.Select(s2, Fe.Neg(s2), S.IsNegative()); // sign(s)·s²
Fe x = Fe.Mul(Fe.Add(a, pms2), y);
x = Fe.Select(x, Fe.Neg(x), x.IsNegative()); // |x|
outFe = Fe.Select(outFe, x, !done);
return (ret, outFe);
}
}
/// <summary>Computes the (at most 8) positive field elements f with this == ElligatorRistrettoFlavor(f),
/// plus a bitmask of which slots are set. Assumes this is even. Port of dalek-signal's
/// <c>elligator_ristretto_flavor_inverse</c>.</summary>
internal (byte mask, Fe[] fes) ElligatorInverse()
{
JacobiPoint[] jcs = ToJacobiQuarticRistretto();
var fes = new Fe[8];
for (int i = 0; i < 8; i++) fes[i] = Fe.One();
byte mask = 0;
for (int i = 0; i < 4; i++)
{
(bool ok0, Fe fe0) = jcs[i].ElligatorInv();
fes[2 * i] = fe0;
if (ok0) mask |= (byte)(1 << (2 * i));
(bool ok1, Fe fe1) = jcs[i].Dual().ElligatorInv();
fes[2 * i + 1] = fe1;
if (ok1) mask |= (byte)(1 << (2 * i + 1));
}
return (mask, fes);
}
private JacobiPoint[] ToJacobiQuarticRistretto()
{
Fe x2 = Fe.Sqr(_x), y2 = Fe.Sqr(_y), y4 = Fe.Sqr(y2), z2 = Fe.Sqr(_z);
Fe zMinY = Fe.Sub(_z, _y), zPlY = Fe.Add(_z, _y);
Fe z2MinY2 = Fe.Sub(z2, y2);
// gamma = 1/sqrt(Y⁴·X²·(Z²−Y²))
(_, Fe gamma) = SqrtRatioM1(Fe.One(), Fe.Mul(Fe.Mul(y4, x2), z2MinY2));
Fe den = Fe.Mul(gamma, y2);
Fe sOverX = Fe.Mul(den, zMinY);
Fe spOverXp = Fe.Mul(den, zPlY);
Fe s0 = Fe.Mul(sOverX, _x);
Fe s1 = Fe.Mul(Fe.Neg(spOverXp), _x);
Fe tmp = Fe.Mul(MDoubleInvSqrtAMinusD, _z);
Fe t0 = Fe.Mul(tmp, sOverX);
Fe t1 = Fe.Mul(tmp, spOverXp);
// den = -1/sqrt(1+d)·(Y²−Z²)·gamma (substitution (X,Y,Z) -> (Y,X,iZ))
Fe den2 = Fe.Mul(Fe.Mul(Fe.Neg(z2MinY2), MInvSqrtOnePlusD), gamma);
Fe iz = Fe.Mul(SqrtM1, _z);
Fe izMinX = Fe.Sub(iz, _x), izPlX = Fe.Add(iz, _x);
Fe sOverY = Fe.Mul(den2, izMinX);
Fe spOverYp = Fe.Mul(den2, izPlX);
Fe s2 = Fe.Mul(sOverY, _y);
Fe s3 = Fe.Mul(Fe.Neg(spOverYp), _y);
Fe tmp2 = Fe.Mul(MDoubleInvSqrtAMinusD, iz);
Fe t2 = Fe.Mul(tmp2, sOverY);
Fe t3 = Fe.Mul(tmp2, spOverYp);
// Special case X=0 or Y=0 (then sᵢ=tᵢ=0): return fixed coset points.
bool xy0 = _x.IsZero() || _y.IsZero();
t0 = Fe.Select(t0, Fe.One(), xy0);
t1 = Fe.Select(t1, Fe.One(), xy0);
t2 = Fe.Select(t2, MiDoubleInvSqrtAMinusD, xy0);
t3 = Fe.Select(t3, MiDoubleInvSqrtAMinusD, xy0);
s2 = Fe.Select(s2, Fe.One(), xy0);
s3 = Fe.Select(s3, Fe.Neg(Fe.One()), xy0);
return new[]
{
new JacobiPoint(s0, t0), new JacobiPoint(s1, t1),
new JacobiPoint(s2, t2), new JacobiPoint(s3, t3),
};
}
}
@@ -0,0 +1,69 @@
using System.Numerics;
namespace Wingnal.Protocol.ZkGroup.Curve;
/// <summary>
/// An integer modulo = 2²⁵² + 27742317777372353535851937790883648493 (the order of the Ristretto255 /
/// edwards25519 prime-order group). Backs zkgroup's scalar arithmetic. Implemented on
/// <see cref="BigInteger"/> for a clear first-correct version — NOT constant-time; harden with the ref10
/// <c>sc_*</c> routines later (see docs/GROUPS.md / SHORTCUTS.md). Canonical wire form is 32 little-endian
/// bytes.
/// </summary>
public readonly struct Scalar25519 : IEquatable<Scalar25519>
{
/// <summary>The group order .</summary>
public static readonly BigInteger L =
BigInteger.Pow(2, 252) + BigInteger.Parse("27742317777372353535851937790883648493");
private readonly BigInteger _v; // always reduced into [0, L)
private Scalar25519(BigInteger v)
{
BigInteger m = v % L;
_v = m.Sign < 0 ? m + L : m;
}
public static Scalar25519 Zero => new(BigInteger.Zero);
public static Scalar25519 One => new(BigInteger.One);
/// <summary>Reduces a 32-byte little-endian value mod .</summary>
public static Scalar25519 FromBytesModOrder(ReadOnlySpan<byte> le32) =>
new(new BigInteger(le32, isUnsigned: true, isBigEndian: false));
/// <summary>Reduces a 64-byte little-endian value mod (uniform hash → scalar).</summary>
public static Scalar25519 FromBytesModOrderWide(ReadOnlySpan<byte> le64) =>
new(new BigInteger(le64, isUnsigned: true, isBigEndian: false));
public static Scalar25519 FromBigInteger(BigInteger v) => new(v);
/// <summary>Parses a 32-byte little-endian scalar, returning null if it is not canonical (≥ ).</summary>
public static Scalar25519? FromCanonicalBytes(ReadOnlySpan<byte> le32)
{
if (le32.Length != 32) return null;
var v = new BigInteger(le32, isUnsigned: true, isBigEndian: false);
return v >= L ? null : new Scalar25519(v);
}
/// <summary>32-byte little-endian canonical encoding.</summary>
public byte[] ToBytes()
{
byte[] raw = _v.ToByteArray(isUnsigned: true, isBigEndian: false);
var result = new byte[32];
Array.Copy(raw, result, Math.Min(raw.Length, 32));
return result;
}
public BigInteger ToBigInteger() => _v;
public static Scalar25519 Add(Scalar25519 a, Scalar25519 b) => new(a._v + b._v);
public static Scalar25519 Sub(Scalar25519 a, Scalar25519 b) => new(a._v - b._v);
public static Scalar25519 Mul(Scalar25519 a, Scalar25519 b) => new(a._v * b._v);
public static Scalar25519 Negate(Scalar25519 a) => new(-a._v);
/// <summary>Multiplicative inverse mod ( is prime, so via Fermat: a^(-2)).</summary>
public Scalar25519 Invert() => new(BigInteger.ModPow(_v, L - 2, L));
public bool Equals(Scalar25519 other) => _v == other._v;
public override bool Equals(object? obj) => obj is Scalar25519 s && Equals(s);
public override int GetHashCode() => _v.GetHashCode();
}
@@ -0,0 +1,150 @@
using System.Buffers.Binary;
using System.Text;
using Org.BouncyCastle.Crypto.Engines;
using Org.BouncyCastle.Crypto.Modes;
using Org.BouncyCastle.Crypto.Parameters;
using Wingnal.Protocol.ZkGroup.Curve;
using Wingnal.Protocol.ZkGroup.Poksho;
using Wingnal.Protocol.ZkGroup.ZkCredential;
namespace Wingnal.Protocol.ZkGroup;
/// <summary>
/// zkgroup's <c>GroupSecretParams</c> derived from the 32-byte group master key (which arrives in the
/// <c>GroupContextV2</c> of group messages and via Storage Service). Provides the 32-byte group identifier
/// (used to key a group conversation) and AES-256-GCM-SIV encryption of the group's title/avatar/etc.
/// "blobs" under the derived blob key. Byte-exact with libsignal zkgroup.
///
/// NOTE: the member-hiding ciphertext + credential layer (UuidCiphertext, AuthCredentialWithPni, …) is
/// NOT here yet — it requires porting the zkcredential crate + the dalek-fork "Lizard" 16-byte→point
/// encoding (see docs/GROUPS.md Phase D remainder). This type covers group-id derivation (enough for
/// receiving group messages) and blob decryption.
/// </summary>
public sealed class GroupSecretParams
{
private const int MasterKeyLen = 32;
public byte[] MasterKey { get; }
public byte[] GroupIdentifier { get; } // 32 bytes
private readonly byte[] _blobKey; // 32-byte AES key
/// <summary>The group's UID verifiable-encryption key pair (encrypts/decrypts member ACIs/PNIs).</summary>
public AttributeKeyPair UidKeyPair { get; }
/// <summary>The group's profile-key verifiable-encryption key pair.</summary>
public AttributeKeyPair ProfileKeyKeyPair { get; }
private GroupSecretParams(byte[] masterKey, byte[] groupId, byte[] blobKey,
AttributeKeyPair uidKeyPair, AttributeKeyPair profileKeyKeyPair)
{
MasterKey = masterKey;
GroupIdentifier = groupId;
_blobKey = blobKey;
UidKeyPair = uidKeyPair;
ProfileKeyKeyPair = profileKeyKeyPair;
}
public static GroupSecretParams Generate(byte[] randomness)
{
var sho = new ShoHmacSha256(Ascii("Signal_ZKGroup_20200424_Random_GroupSecretParams_Generate"));
sho.AbsorbAndRatchet(randomness);
return DeriveFromMasterKey(sho.SqueezeAndRatchet(MasterKeyLen));
}
public static GroupSecretParams DeriveFromMasterKey(byte[] masterKey)
{
if (masterKey.Length != MasterKeyLen) throw new ArgumentException("master key must be 32 bytes");
var sho = new ShoHmacSha256(
Ascii("Signal_ZKGroup_20200424_GroupMasterKey_GroupSecretParams_DeriveFromMasterKey"));
sho.AbsorbAndRatchet(masterKey);
byte[] groupId = sho.SqueezeAndRatchet(32);
byte[] blobKey = sho.SqueezeAndRatchet(32);
// The SAME sho continues into both encryption key pairs (order: uid then profile-key).
AttributeKeyPair uidKeyPair = UidEncryption.DeriveKeyPair(sho);
AttributeKeyPair profileKeyKeyPair = ProfileKeyEncryption.DeriveKeyPair(sho);
return new GroupSecretParams((byte[])masterKey.Clone(), groupId, blobKey, uidKeyPair, profileKeyKeyPair);
}
// ── public params + member-ciphertext helpers (the visible part of the group) ──
/// <summary>The group's public params: group id + the two encryption public keys (97 bytes serialized).</summary>
public byte[] PublicParamsSerialized()
{
var b = new byte[97];
b[0] = 0; // reserved
Array.Copy(GroupIdentifier, 0, b, 1, 32);
Array.Copy(UidKeyPair.PublicKey.Encode(), 0, b, 33, 32);
Array.Copy(ProfileKeyKeyPair.PublicKey.Encode(), 0, b, 65, 32);
return b;
}
public UuidCiphertext EncryptServiceId(ServiceId serviceId) =>
new(UidEncryption.Encrypt(UidKeyPair, UidStruct.FromServiceId(serviceId)));
public ServiceId DecryptServiceId(UuidCiphertext ciphertext) =>
UidEncryption.Decrypt(UidKeyPair, ciphertext.Ciphertext);
public ProfileKeyCiphertext EncryptProfileKey(byte[] profileKey32, byte[] aciUuid16) =>
new(ProfileKeyEncryption.Encrypt(ProfileKeyKeyPair, ProfileKeyStruct.New(profileKey32, aciUuid16)));
public byte[] DecryptProfileKey(ProfileKeyCiphertext ciphertext, byte[] aciUuid16) =>
ProfileKeyEncryption.Decrypt(ProfileKeyKeyPair, ciphertext.Ciphertext, aciUuid16);
// ── blob encryption (AES-256-GCM-SIV; RFC 8452) ──
public byte[] EncryptBlobWithPadding(byte[] randomness, byte[] plaintext, uint paddingLen)
{
var padded = new byte[4 + plaintext.Length + (int)paddingLen];
BinaryPrimitives.WriteUInt32BigEndian(padded, paddingLen);
Array.Copy(plaintext, 0, padded, 4, plaintext.Length);
return EncryptBlob(randomness, padded);
}
public byte[] EncryptBlob(byte[] randomness, byte[] plaintext)
{
var sho = new ShoHmacSha256(Ascii("Signal_ZKGroup_20200424_Random_GroupSecretParams_EncryptBlob"));
sho.AbsorbAndRatchet(randomness);
byte[] nonce = sho.SqueezeAndRatchet(12);
byte[] ct = GcmSiv(forEncryption: true, _blobKey, nonce, plaintext);
var result = new byte[ct.Length + 12 + 1]; // ciphertext‖nonce‖reserved(0)
Array.Copy(ct, result, ct.Length);
Array.Copy(nonce, 0, result, ct.Length, 12);
return result;
}
public byte[] DecryptBlobWithPadding(byte[] ciphertext)
{
byte[] dec = DecryptBlob(ciphertext);
if (dec.Length < 4) throw new ArgumentException("blob too short");
uint padLen = BinaryPrimitives.ReadUInt32BigEndian(dec);
int plen = dec.Length - 4 - (int)padLen;
if (plen < 0) throw new ArgumentException("bad padding length");
var pt = new byte[plen];
Array.Copy(dec, 4, pt, 0, plen);
return pt;
}
public byte[] DecryptBlob(byte[] ciphertext)
{
if (ciphertext.Length < 12 + 1) throw new ArgumentException("blob too short");
int unreserved = ciphertext.Length - 1; // drop trailing reserved byte
var nonce = new byte[12];
Array.Copy(ciphertext, unreserved - 12, nonce, 0, 12);
var ct = new byte[unreserved - 12];
Array.Copy(ciphertext, 0, ct, 0, ct.Length);
return GcmSiv(forEncryption: false, _blobKey, nonce, ct);
}
private static byte[] GcmSiv(bool forEncryption, byte[] key, byte[] nonce, byte[] input)
{
var cipher = new GcmSivBlockCipher(new AesEngine());
cipher.Init(forEncryption, new AeadParameters(new KeyParameter(key), 128, nonce));
var outBuf = new byte[cipher.GetOutputSize(input.Length)];
int n = cipher.ProcessBytes(input, 0, input.Length, outBuf, 0);
n += cipher.DoFinal(outBuf, n);
if (n != outBuf.Length) Array.Resize(ref outBuf, n);
return outBuf;
}
private static byte[] Ascii(string s) => Encoding.ASCII.GetBytes(s);
}
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using Wingnal.Protocol.ZkGroup.Curve;
namespace Wingnal.Protocol.ZkGroup.Poksho;
/// <summary>
/// poksho's Schnorr signature (<c>poksho::sign</c>/<c>verify_signature</c>): a one-equation proof of
/// knowledge of the discrete log of a public key (<c>public_key = private_key·G</c>) bound to a message.
/// zkgroup signs each <c>GroupChange</c> with this (the server's sig key); the client verifies it before
/// applying a change. Reuses the byte-exact <see cref="Statement"/> engine, so signatures are interoperable
/// with libsignal. Validated against poksho's own signature vector.
/// </summary>
public static class PokshoSignature
{
/// <summary>Verifies a 64-byte signature over <paramref name="message"/> by <paramref name="publicKey"/>.</summary>
public static bool Verify(byte[] signature, Ristretto255 publicKey, byte[] message)
{
Statement st = SignatureStatement();
var points = new Dictionary<string, Ristretto255> { ["public_key"] = publicKey };
return st.VerifyProof(signature, points, message);
}
/// <summary>Produces a signature (needs the private scalar; mainly for offline testing).</summary>
public static byte[] Sign(Scalar25519 privateKey, Ristretto255 publicKey, byte[] message, byte[] randomness)
{
Statement st = SignatureStatement();
var scalars = new Dictionary<string, Scalar25519> { ["private_key"] = privateKey };
var points = new Dictionary<string, Ristretto255> { ["public_key"] = publicKey };
return st.Prove(scalars, points, message, randomness);
}
private static Statement SignatureStatement()
{
var st = new Statement();
st.Add("public_key", ("private_key", "G")); // G = the Ristretto basepoint (statement index 0)
return st;
}
}
+192
View File
@@ -0,0 +1,192 @@
using System.Collections.Generic;
using System.Linq;
using Wingnal.Protocol.ZkGroup.Curve;
namespace Wingnal.Protocol.ZkGroup.Poksho;
/// <summary>
/// Byte-exact port of libsignal poksho's Sigma/Schnorr proof system for arbitrary linear relations
/// (Boneh-Shoup §19.5.3) over Ristretto255. A <see cref="Statement"/> is a set of equations
/// "P = Σ scalarᵢ·pointᵢ"; <see cref="Statement.Prove"/> produces a Fiat-Shamir proof of knowledge of the
/// witness scalars, and <see cref="Statement.VerifyProof"/> checks it. The Fiat-Shamir transcript uses
/// <see cref="ShoHmacSha256"/> with label "POKSHO_Ristretto_SHOHMACSHA256". zkgroup credentials are all
/// expressed as poksho statements. Validated against poksho's own prove/verify test vector.
/// </summary>
public sealed class Statement
{
private static readonly byte[] Label =
System.Text.Encoding.ASCII.GetBytes("POKSHO_Ristretto_SHOHMACSHA256");
private readonly record struct Term(byte Scalar, byte Point);
private readonly record struct Equation(byte Lhs, List<Term> Rhs);
private readonly List<Equation> _equations = new();
private readonly Dictionary<string, byte> _scalarMap = new();
private readonly List<string> _scalarVec = new();
private readonly Dictionary<string, byte> _pointMap = new() { ["G"] = 0 };
private readonly List<string> _pointVec = new() { "G" }; // index 0 = Ristretto base point
/// <summary>Adds the equation lhs = Σ (scalar·point) over the given (scalarName, pointName) terms.</summary>
public void Add(string lhs, params (string scalar, string point)[] rhs)
{
if (string.IsNullOrEmpty(lhs) || rhs.Length == 0 || rhs.Length > 255 || _equations.Count >= 255)
throw new ArgumentException("poksho: bad statement sizes");
byte lhsIdx = AddPoint(lhs);
var terms = new List<Term>(rhs.Length);
foreach ((string s, string p) in rhs)
{
if (string.IsNullOrEmpty(s) || string.IsNullOrEmpty(p)) throw new ArgumentException("poksho: empty name");
terms.Add(new Term(AddScalar(s), AddPoint(p)));
}
_equations.Add(new Equation(lhsIdx, terms));
}
private byte AddScalar(string name)
{
if (_scalarMap.TryGetValue(name, out byte i)) return i;
byte idx = checked((byte)_scalarMap.Count);
_scalarMap[name] = idx; _scalarVec.Add(name);
return idx;
}
private byte AddPoint(string name)
{
if (_pointMap.TryGetValue(name, out byte i)) return i;
byte idx = checked((byte)_pointMap.Count);
_pointMap[name] = idx; _pointVec.Add(name);
return idx;
}
internal byte[] ToBytes()
{
var v = new List<byte> { (byte)_equations.Count };
foreach (Equation e in _equations)
{
v.Add(e.Lhs);
v.Add((byte)e.Rhs.Count);
foreach (Term t in e.Rhs) { v.Add(t.Scalar); v.Add(t.Point); }
}
return v.ToArray();
}
private Scalar25519[] SortScalars(IReadOnlyDictionary<string, Scalar25519> args)
{
if (args.Count != _scalarVec.Count) throw new ArgumentException("poksho: wrong number of scalar args");
return _scalarVec.Select(n => args.TryGetValue(n, out Scalar25519 s)
? s : throw new ArgumentException($"poksho: missing scalar {n}")).ToArray();
}
private Ristretto255[] SortPoints(IReadOnlyDictionary<string, Ristretto255> args)
{
if (args.Count != _pointVec.Count - 1) throw new ArgumentException("poksho: wrong number of point args");
var pts = new Ristretto255[_pointVec.Count];
pts[0] = Ristretto255.BasePoint;
for (int i = 1; i < _pointVec.Count; i++)
pts[i] = args.TryGetValue(_pointVec[i], out Ristretto255? p)
? p! : throw new ArgumentException($"poksho: missing point {_pointVec[i]}");
return pts;
}
// commitment[eq] = Σ g1[scalar]·points[point] (+ (-challenge)·points[lhs] when verifying)
private Ristretto255[] Homomorphism(Scalar25519[] g1, Ristretto255[] points, Scalar25519? challenge)
{
var result = new Ristretto255[_equations.Count];
for (int k = 0; k < _equations.Count; k++)
{
Equation e = _equations[k];
Ristretto255 acc = Ristretto255.Identity;
foreach (Term t in e.Rhs)
acc = Ristretto255.Add(acc, points[t.Point].Multiply(g1[t.Scalar]));
if (challenge is { } h)
acc = Ristretto255.Add(acc, points[e.Lhs].Multiply(Scalar25519.Negate(h)));
result[k] = acc;
}
return result;
}
public byte[] Prove(IReadOnlyDictionary<string, Scalar25519> scalarArgs,
IReadOnlyDictionary<string, Ristretto255> pointArgs, byte[] message, byte[] randomness)
{
if (randomness.Length != 32) throw new ArgumentException("poksho: randomness must be 32 bytes");
Scalar25519[] g1 = SortScalars(scalarArgs);
Ristretto255[] allPoints = SortPoints(pointArgs);
var sho = new ShoHmacSha256(Label);
sho.Absorb(ToBytes()); // D
foreach (Ristretto255 p in allPoints) sho.Absorb(p.Encode()); // A
sho.Ratchet();
// Synthetic nonce: hash randomness ‖ witness ‖ message in a forked transcript.
ShoHmacSha256 sho2 = sho.Clone();
sho2.Absorb(randomness); // Z
foreach (Scalar25519 s in g1) sho2.Absorb(s.ToBytes()); // a
sho2.Ratchet();
sho2.AbsorbAndRatchet(message); // M
byte[] nonceBytes = sho2.SqueezeAndRatchet(g1.Length * 64);
var nonce = new Scalar25519[g1.Length];
for (int i = 0; i < g1.Length; i++)
nonce[i] = Scalar25519.FromBytesModOrderWide(nonceBytes.AsSpan(i * 64, 64));
Ristretto255[] commitment = Homomorphism(nonce, allPoints, null);
foreach (Ristretto255 r in commitment) sho.Absorb(r.Encode()); // R
sho.AbsorbAndRatchet(message); // M
Scalar25519 challenge = Scalar25519.FromBytesModOrderWide(sho.SqueezeAndRatchet(64));
var response = new Scalar25519[g1.Length];
for (int i = 0; i < g1.Length; i++)
response[i] = Scalar25519.Add(nonce[i], Scalar25519.Mul(g1[i], challenge));
byte[] proof = SerializeProof(challenge, response);
if (!VerifyProof(proof, pointArgs, message)) // self-check before returning
throw new InvalidOperationException("poksho: proof failed self-verification");
return proof;
}
public bool VerifyProof(byte[] proofBytes, IReadOnlyDictionary<string, Ristretto255> pointArgs, byte[] message)
{
if (!TryParseProof(proofBytes, out Scalar25519 challenge, out Scalar25519[] response)) return false;
if (response.Length != _scalarVec.Count) return false;
Ristretto255[] allPoints;
try { allPoints = SortPoints(pointArgs); }
catch (ArgumentException) { throw; } // wrong number of point args is a usage error, not a failure
var sho = new ShoHmacSha256(Label);
sho.Absorb(ToBytes());
foreach (Ristretto255 p in allPoints) sho.Absorb(p.Encode());
sho.Ratchet();
Ristretto255[] commitment = Homomorphism(response, allPoints, challenge);
foreach (Ristretto255 r in commitment) sho.Absorb(r.Encode());
sho.AbsorbAndRatchet(message);
Scalar25519 expected = Scalar25519.FromBytesModOrderWide(sho.SqueezeAndRatchet(64));
return expected.Equals(challenge);
}
private static byte[] SerializeProof(Scalar25519 challenge, Scalar25519[] response)
{
var v = new List<byte>(challenge.ToBytes());
foreach (Scalar25519 s in response) v.AddRange(s.ToBytes());
return v.ToArray();
}
private static bool TryParseProof(byte[] bytes, out Scalar25519 challenge, out Scalar25519[] response)
{
challenge = default; response = System.Array.Empty<Scalar25519>();
if (bytes.Length == 0 || bytes.Length % 32 != 0) return false;
int count = bytes.Length / 32;
if (count < 2 || count > 257) return false; // challenge + 1..256 responses
Scalar25519? ch = Scalar25519.FromCanonicalBytes(bytes.AsSpan(0, 32));
if (ch is null) return false;
challenge = ch.Value;
var resp = new Scalar25519[count - 1];
for (int i = 1; i < count; i++)
{
Scalar25519? s = Scalar25519.FromCanonicalBytes(bytes.AsSpan(i * 32, 32));
if (s is null) return false;
resp[i - 1] = s.Value;
}
response = resp;
return true;
}
}
@@ -0,0 +1,97 @@
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Security.Cryptography;
namespace Wingnal.Protocol.ZkGroup.Poksho;
/// <summary>
/// Byte-exact port of libsignal poksho's <c>ShoHmacSha256</c> — a "stateful hash object" (sponge over
/// HMAC-SHA256) used throughout zkgroup for Fiat-Shamir transcripts and for deriving scalars/points.
/// Absorbing appends to an HMAC keyed by the chaining value; ratchet finalizes (message‖0x00) into a new
/// chaining value; squeeze is an HMAC-PRF expansion keyed by the chaining value over (BE64(i)‖0x01), and
/// re-ratchets via (BE64(outlen)‖0x02). Validated against poksho's own test vectors.
/// </summary>
public sealed class ShoHmacSha256
{
private const int HashLen = 32;
private byte[] _cv = new byte[HashLen]; // chaining value (starts all-zero, mode = RATCHETED)
private byte[] _key = new byte[HashLen]; // HMAC key in use while ABSORBING (the cv at absorb time)
private readonly List<byte> _buffer = new();
private bool _absorbing; // false = RATCHETED
public ShoHmacSha256(ReadOnlySpan<byte> label) => AbsorbAndRatchet(label);
private ShoHmacSha256() { }
/// <summary>Deep copy of the current state (poksho proves fork the transcript with a clone).</summary>
public ShoHmacSha256 Clone()
{
var c = new ShoHmacSha256
{
_cv = (byte[])_cv.Clone(),
_key = (byte[])_key.Clone(),
_absorbing = _absorbing,
};
c._buffer.AddRange(_buffer);
return c;
}
public void Absorb(ReadOnlySpan<byte> input)
{
if (!_absorbing)
{
_key = (byte[])_cv.Clone();
_buffer.Clear();
_absorbing = true;
}
_buffer.AddRange(input.ToArray());
}
public void Ratchet()
{
if (!_absorbing) return;
_buffer.Add(0x00);
_cv = Hmac(_key, _buffer.ToArray());
_buffer.Clear();
_absorbing = false;
}
public void AbsorbAndRatchet(ReadOnlySpan<byte> input) { Absorb(input); Ratchet(); }
public byte[] SqueezeAndRatchet(int outlen)
{
if (_absorbing) throw new InvalidOperationException("ShoHmacSha256: must ratchet before squeezing");
var output = new byte[outlen];
int pos = 0;
for (int i = 0; i * HashLen < outlen; i++)
{
var msg = new byte[9];
BinaryPrimitives.WriteUInt64BigEndian(msg, (ulong)i);
msg[8] = 0x01;
byte[] digest = Hmac(_cv, msg);
int num = Math.Min(HashLen, outlen - i * HashLen);
Array.Copy(digest, 0, output, pos, num);
pos += num;
}
var next = new byte[9];
BinaryPrimitives.WriteUInt64BigEndian(next, (ulong)outlen);
next[8] = 0x02;
_cv = Hmac(_cv, next);
return output;
}
/// <summary>squeeze 64 bytes → scalar mod (poksho ShoExt.get_scalar).</summary>
public Curve.Scalar25519 GetScalar() => Curve.Scalar25519.FromBytesModOrderWide(SqueezeAndRatchet(64));
/// <summary>squeeze 64 bytes → a pseudorandom Ristretto point (poksho ShoExt.get_point).</summary>
public Curve.Ristretto255 GetPoint() => Curve.Ristretto255.FromUniformBytes(SqueezeAndRatchet(64));
private static byte[] Hmac(byte[] key, byte[] message)
{
using var h = new HMACSHA256(key);
return h.ComputeHash(message);
}
}
@@ -0,0 +1,76 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
namespace Wingnal.Protocol.ZkGroup.Poksho;
/// <summary>
/// Byte-exact port of poksho's <c>ShoSha256</c> — the "innerpad" stateful hash object over SHA-256 (the
/// non-HMAC sibling of <see cref="ShoHmacSha256"/>). zkcredential's generic credential <c>SystemParams</c>
/// are derived through this. Absorbing prefixes a zero block + the chaining value; ratchet double-hashes;
/// squeeze is an SHA-256 PRF over (63 zeros‖0x01‖cv‖BE64(i)) re-ratcheting via (…‖0x02‖cv‖BE64(len)).
/// Validated against poksho's own test vectors.
/// </summary>
public sealed class ShoSha256
{
private const int BlockLen = 64;
private const int HashLen = 32;
private byte[] _cv = new byte[HashLen];
private IncrementalHash _hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
private bool _absorbing; // false = RATCHETED
public ShoSha256(ReadOnlySpan<byte> label) => AbsorbAndRatchet(label);
public void Absorb(ReadOnlySpan<byte> input)
{
if (!_absorbing)
{
_hasher.AppendData(new byte[BlockLen]); // 64 zero bytes
_hasher.AppendData(_cv);
_absorbing = true;
}
_hasher.AppendData(input);
}
public void Ratchet()
{
if (!_absorbing) return;
byte[] once = _hasher.GetHashAndReset();
_cv = SHA256.HashData(once); // double hash
_absorbing = false;
}
public void AbsorbAndRatchet(ReadOnlySpan<byte> input) { Absorb(input); Ratchet(); }
public byte[] SqueezeAndRatchet(int outlen)
{
if (_absorbing) throw new InvalidOperationException("ShoSha256: must ratchet before squeezing");
var output = new byte[outlen];
for (int i = 0; i * HashLen < outlen; i++)
{
using var h = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
h.AppendData(new byte[BlockLen - 1]); // 63 zero bytes
h.AppendData(new byte[] { 0x01 });
h.AppendData(_cv);
Span<byte> ctr = stackalloc byte[8];
BinaryPrimitives.WriteUInt64BigEndian(ctr, (ulong)i);
h.AppendData(ctr);
byte[] digest = h.GetHashAndReset();
int num = Math.Min(HashLen, outlen - i * HashLen);
Array.Copy(digest, 0, output, i * HashLen, num);
}
using var next = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
next.AppendData(new byte[BlockLen - 1]);
next.AppendData(new byte[] { 0x02 });
next.AppendData(_cv);
Span<byte> lenBe = stackalloc byte[8];
BinaryPrimitives.WriteUInt64BigEndian(lenBe, (ulong)outlen);
next.AppendData(lenBe);
_cv = next.GetHashAndReset();
return output;
}
/// <summary>squeeze 64 bytes → a pseudorandom Ristretto point (poksho ShoExt.get_point).</summary>
public Curve.Ristretto255 GetPoint() => Curve.Ristretto255.FromUniformBytes(SqueezeAndRatchet(64));
}
@@ -0,0 +1,109 @@
using System.Text;
using Wingnal.Protocol.ZkGroup.Curve;
using Wingnal.Protocol.ZkGroup.Poksho;
using Wingnal.Protocol.ZkGroup.ZkCredential;
namespace Wingnal.Protocol.ZkGroup;
/// <summary>
/// zkgroup's profile-key attribute (<c>ProfileKeyStruct</c>): M3 = single-Elligator hash of (profileKey‖uid),
/// M4 = single-Elligator encoding of the (bit-masked) 32-byte profile key. Verifiably-encrypted into a
/// <see cref="ProfileKeyCiphertext"/>; decryption recovers the profile key by inverting Elligator on M4 and
/// checking each candidate against M3.
/// </summary>
public readonly struct ProfileKeyStruct
{
public readonly Ristretto255 M3;
public readonly Ristretto255 M4;
public readonly byte[] ProfileKey; // 32 bytes (the original, un-masked)
private ProfileKeyStruct(Ristretto255 m3, Ristretto255 m4, byte[] profileKey)
{
M3 = m3; M4 = m4; ProfileKey = profileKey;
}
public static ProfileKeyStruct New(byte[] profileKey32, byte[] uid16)
{
if (profileKey32.Length != 32 || uid16.Length != 16) throw new ArgumentException("bad sizes");
var encoded = (byte[])profileKey32.Clone();
encoded[0] &= 254;
encoded[31] &= 63;
Ristretto255 m3 = CalcM3(profileKey32, uid16);
Ristretto255 m4 = Ristretto255.FromSingleElligatorBytes(encoded);
return new ProfileKeyStruct(m3, m4, profileKey32);
}
internal static Ristretto255 CalcM3(byte[] profileKey32, byte[] uid16)
{
var sho = new ShoHmacSha256(
Encoding.ASCII.GetBytes("Signal_ZKGroup_20200424_ProfileKeyAndUid_ProfileKey_CalcM3"));
var combined = new byte[48];
Array.Copy(profileKey32, 0, combined, 0, 32);
Array.Copy(uid16, 0, combined, 32, 16);
sho.AbsorbAndRatchet(combined);
return Ristretto255.FromSingleElligatorBytes(sho.SqueezeAndRatchet(32));
}
public Ristretto255[] AsPoints() => new[] { M3, M4 };
}
/// <summary>The profile-key verifiable-encryption domain (analogous to <see cref="UidEncryption"/>).</summary>
public static class ProfileKeyEncryption
{
public const string DomainId = "Signal_ZKGroup_20231011_ProfileKeyEncryption";
public static readonly (Ristretto255 Gb1, Ristretto255 Gb2) SystemParams = GenerateSystemParams();
private static (Ristretto255, Ristretto255) GenerateSystemParams()
{
var sho = new ShoHmacSha256(Encoding.ASCII.GetBytes(
"Signal_ZKGroup_20200424_Constant_ProfileKeyEncryption_SystemParams_Generate"));
sho.AbsorbAndRatchet(Array.Empty<byte>());
Ristretto255 gb1 = Ristretto255.FromUniformBytes(sho.SqueezeAndRatchet(64));
Ristretto255 gb2 = Ristretto255.FromUniformBytes(sho.SqueezeAndRatchet(64));
return (gb1, gb2);
}
public static readonly byte[] SystemHardcoded =
{
0xf6, 0xba, 0xa3, 0x17, 0xce, 0x18, 0x39, 0xc9, 0x3d, 0x61, 0x7e, 0x0c, 0xd8, 0x37, 0xd1,
0x9d, 0xa9, 0xc8, 0xa4, 0xc5, 0x20, 0xbf, 0x7c, 0x51, 0xb1, 0xe6, 0xc2, 0xcb, 0x2a, 0x04,
0x9c, 0x61, 0x2e, 0x01, 0x75, 0x89, 0x4c, 0x87, 0x30, 0xb2, 0x03, 0xab, 0x3b, 0xd9, 0x8e,
0xcb, 0x2d, 0x81, 0xab, 0xac, 0xb6, 0x5f, 0x8a, 0x61, 0x24, 0xf4, 0x97, 0x71, 0xd1, 0x4a,
0x98, 0x52, 0x12, 0x0c,
};
public static AttributeKeyPair DeriveKeyPair(ShoHmacSha256 sho) =>
AttributeKeyPair.DeriveFrom(sho, SystemParams.Gb1, SystemParams.Gb2);
public static AttributeCiphertext Encrypt(AttributeKeyPair keyPair, ProfileKeyStruct pk) =>
keyPair.Encrypt(pk.M3, pk.M4);
/// <summary>Decrypts a profile-key ciphertext back to the 32-byte profile key, given the member's uid.
/// Port of zkgroup <c>ProfileKeyEncryptionDomain::decrypt</c>.</summary>
public static byte[] Decrypt(AttributeKeyPair keyPair, AttributeCiphertext ct, byte[] uid16)
{
Ristretto255 m4 = keyPair.DecryptToSecondPoint(ct);
(byte mask, Fe[] fes) = m4.ElligatorInverse();
Ristretto255 targetM3 = ct.EA1.Multiply(keyPair.A1.Invert());
byte[]? result = null;
int found = 0;
for (int i = 0; i < 8; i++)
{
if (((mask >> i) & 1) == 0) continue;
byte[] candidate = fes[i].Encode(); // 32-byte field-element encoding
for (int j = 0; j < 8; j++)
{
var pk = (byte[])candidate.Clone();
if (((j >> 2) & 1) == 1) pk[0] |= 0x01;
if (((j >> 1) & 1) == 1) pk[31] |= 0x80;
if ((j & 1) == 1) pk[31] |= 0x40;
Ristretto255 m3 = ProfileKeyStruct.CalcM3(pk, uid16);
if (m3.ConstantTimeEquals(targetM3)) { result = pk; found++; }
}
}
if (found != 1 || result is null) throw new ZkGroupVerificationException("profile key decrypt failed");
return result;
}
}
@@ -0,0 +1,65 @@
using Wingnal.Protocol.ZkGroup.Curve;
using Wingnal.Protocol.ZkGroup.ZkCredential;
namespace Wingnal.Protocol.ZkGroup;
/// <summary>
/// Signal's published <c>ServerPublicParams</c> — the server's public credential/signature keys that every
/// client embeds (it is NOT derivable; like the pinned CA, it is fixed production data). We only need two of
/// its fields: the <see cref="GenericCredentialPublicKey"/> (to receive + present the AuthCredentialWithPni)
/// and the <see cref="SigPublicKey"/> (to verify the server's signature on a GroupChange).
///
/// Layout is bincode in struct-field order (total <c>SERVER_PUBLIC_PARAMS_LEN</c> = 673):
/// reserved(1) ‖ 6×oldCredentialPublicKey(64 = C_W‖I) with sig_public_key(32) as the 3rd field ‖
/// generic_credential_public_key(224 = C_W‖I[6]) ‖ endorsement_public_key(32). So sig = [129,161),
/// generic = [417,641).
/// </summary>
public sealed class ServerPublicParams
{
public const int SerializedLen = 673;
private const int SigPublicKeyOffset = 129;
private const int GenericCredentialOffset = 417;
private const int GenericCredentialLen = 224;
public CredentialPublicKey GenericCredentialPublicKey { get; }
public Ristretto255 SigPublicKey { get; }
private ServerPublicParams(CredentialPublicKey generic, Ristretto255 sig)
{
GenericCredentialPublicKey = generic;
SigPublicKey = sig;
}
public static ServerPublicParams Parse(ReadOnlySpan<byte> bytes)
{
if (bytes.Length != SerializedLen)
throw new ArgumentException($"ServerPublicParams must be {SerializedLen} bytes, got {bytes.Length}");
if (bytes[0] != 0) throw new ArgumentException("ServerPublicParams: bad reserved byte");
Ristretto255 sig = Ristretto255.Decode(bytes.Slice(SigPublicKeyOffset, 32))
?? throw new ArgumentException("ServerPublicParams: bad sig public key");
CredentialPublicKey generic = CredentialPublicKey.Deserialize(
bytes.Slice(GenericCredentialOffset, GenericCredentialLen));
return new ServerPublicParams(generic, sig);
}
/// <summary>The base64 of Signal's PRODUCTION ServerPublicParams (from Signal-Android
/// <c>BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS</c>; the staging value differs).</summary>
public const string ProductionBase64 =
"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8" +
"Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt" +
"8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGv" +
"CoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQF" +
"j5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCn" +
"ImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw" +
"3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw" +
"2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6w" +
"kU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCW" +
"JmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPV" +
"OOmR0RW6bgRvc7DSF20V/omg+YBw==";
private static readonly Lazy<ServerPublicParams> _production =
new(() => Parse(Convert.FromBase64String(ProductionBase64)));
/// <summary>The parsed production server public params.</summary>
public static ServerPublicParams Production => _production.Value;
}
+114
View File
@@ -0,0 +1,114 @@
using System.Text;
using Wingnal.Protocol.ZkGroup.Curve;
using Wingnal.Protocol.ZkGroup.Poksho;
using Wingnal.Protocol.ZkGroup.ZkCredential;
namespace Wingnal.Protocol.ZkGroup;
/// <summary>A Signal service id (ACI or PNI) as the 16-byte raw UUID plus its kind, for zkgroup encoding.</summary>
public readonly struct ServiceId
{
public readonly byte[] RawUuid; // 16 bytes
public readonly bool IsPni;
public ServiceId(byte[] rawUuid16, bool isPni)
{
if (rawUuid16.Length != 16) throw new ArgumentException("uuid must be 16 bytes");
RawUuid = rawUuid16; IsPni = isPni;
}
public static ServiceId Aci(byte[] uuid16) => new(uuid16, isPni: false);
public static ServiceId Pni(byte[] uuid16) => new(uuid16, isPni: true);
/// <summary>libsignal-core <c>service_id_binary</c>: ACI = 16 raw bytes; PNI = 0x01‖16.</summary>
public byte[] ServiceIdBinary()
{
if (!IsPni) return (byte[])RawUuid.Clone();
var b = new byte[17];
b[0] = 0x01;
Array.Copy(RawUuid, 0, b, 1, 16);
return b;
}
}
/// <summary>
/// zkgroup's UID attribute (<c>UidStruct</c>): M1 = hash-to-group of the service-id binary; M2 = the Lizard
/// encoding of the raw 16-byte UUID. The pair is verifiably-encrypted into a <see cref="UuidCiphertext"/>.
/// </summary>
public readonly struct UidStruct
{
public readonly Ristretto255 M1;
public readonly Ristretto255 M2;
public readonly byte[] RawUuid;
private UidStruct(Ristretto255 m1, Ristretto255 m2, byte[] rawUuid) { M1 = m1; M2 = m2; RawUuid = rawUuid; }
public static UidStruct FromServiceId(ServiceId sid)
{
Ristretto255 m1 = CalcM1(sid);
Ristretto255 m2 = Lizard.Encode(sid.RawUuid);
return new UidStruct(m1, m2, sid.RawUuid);
}
internal static Ristretto255 CalcM1(ServiceId sid)
{
var sho = new ShoHmacSha256(Encoding.ASCII.GetBytes("Signal_ZKGroup_20200424_UID_CalcM1"));
sho.AbsorbAndRatchet(sid.ServiceIdBinary());
return Ristretto255.FromUniformBytes(sho.SqueezeAndRatchet(64));
}
public Ristretto255[] AsPoints() => new[] { M1, M2 };
}
/// <summary>
/// The UID verifiable-encryption domain: a fixed pair of generator points (G_a1, G_a2) derived
/// deterministically via the SHO. <see cref="SystemHardcoded"/> is libsignal's pinned serialization of
/// these two points and gates the derivation byte-for-byte.
/// </summary>
public static class UidEncryption
{
public const string DomainId = "Signal_ZKGroup_20230419_UidEncryption";
public static readonly (Ristretto255 Ga1, Ristretto255 Ga2) SystemParams = GenerateSystemParams();
private static (Ristretto255, Ristretto255) GenerateSystemParams()
{
var sho = new ShoHmacSha256(
Encoding.ASCII.GetBytes("Signal_ZKGroup_20200424_Constant_UidEncryption_SystemParams_Generate"));
sho.AbsorbAndRatchet(Array.Empty<byte>());
Ristretto255 ga1 = Ristretto255.FromUniformBytes(sho.SqueezeAndRatchet(64));
Ristretto255 ga2 = Ristretto255.FromUniformBytes(sho.SqueezeAndRatchet(64));
return (ga1, ga2);
}
/// <summary>zkgroup's pinned 64-byte serialization of (G_a1, G_a2) — the Phase D2 test gate.</summary>
public static readonly byte[] SystemHardcoded =
{
0xa6, 0x32, 0x4c, 0x36, 0x8d, 0xf7, 0x34, 0x69, 0x11, 0x47, 0x98, 0x13, 0x48, 0xb6, 0xe7,
0xeb, 0x42, 0xc3, 0x30, 0x7e, 0x71, 0x1b, 0x6c, 0x7e, 0xcc, 0xd3, 0x03, 0x2d, 0x45, 0x69,
0x3f, 0x5a, 0x04, 0x80, 0x13, 0x52, 0x5b, 0x76, 0x12, 0x4b, 0xf2, 0x64, 0x0c, 0x5e, 0x93,
0x69, 0xc7, 0x6e, 0xfb, 0xe8, 0x0a, 0xba, 0x2a, 0x24, 0xaa, 0x5d, 0x8e, 0x18, 0xa9, 0x8e,
0xba, 0x14, 0xf8, 0x37,
};
public static AttributeKeyPair DeriveKeyPair(ShoHmacSha256 sho) =>
AttributeKeyPair.DeriveFrom(sho, SystemParams.Ga1, SystemParams.Ga2);
public static AttributeCiphertext Encrypt(AttributeKeyPair keyPair, UidStruct uid) =>
keyPair.Encrypt(uid.M1, uid.M2);
/// <summary>Decrypts a UID ciphertext back to a service id, trying both ACI and PNI interpretations and
/// confirming via M1 (zkgroup <c>UidEncryptionDomain::decrypt</c>).</summary>
public static ServiceId Decrypt(AttributeKeyPair keyPair, AttributeCiphertext ct)
{
Ristretto255 m2 = keyPair.DecryptToSecondPoint(ct);
byte[]? uuid = Lizard.Decode(m2) ?? throw new ZkGroupVerificationException("lizard decode failed");
var aci = ServiceId.Aci(uuid);
var pni = ServiceId.Pni(uuid);
Ristretto255 decryptedM1 = ct.EA1.Multiply(keyPair.A1.Invert());
if (decryptedM1.ConstantTimeEquals(UidStruct.CalcM1(aci))) return aci;
if (decryptedM1.ConstantTimeEquals(UidStruct.CalcM1(pni))) return pni;
throw new ZkGroupVerificationException("uid ciphertext did not match ACI or PNI");
}
}
@@ -0,0 +1,90 @@
using Wingnal.Protocol.ZkGroup.Curve;
using Wingnal.Protocol.ZkGroup.Poksho;
namespace Wingnal.Protocol.ZkGroup.ZkCredential;
/// <summary>
/// Port of libsignal's <c>zkcredential::attributes</c> verifiable-encryption layer (Chase-Perrin-Zaverucha
/// §4.1). An attribute is a pair of Ristretto points (M1, M2). A <see cref="AttributeKeyPair"/> holds two
/// scalars (a1, a2); encryption is <c>E_A1 = a1·M1; E_A2 = a2·E_A1 + M2</c>. The verifying server can match
/// the ciphertext without learning the plaintext, which is how a group hides its members' ACIs/profile keys.
/// </summary>
public readonly struct AttributeCiphertext
{
public readonly Ristretto255 EA1;
public readonly Ristretto255 EA2;
public AttributeCiphertext(Ristretto255 ea1, Ristretto255 ea2) { EA1 = ea1; EA2 = ea2; }
/// <summary>The ciphertext as its own attribute (for chaining), per zkcredential.</summary>
public Ristretto255[] AsPoints() => new[] { EA1, EA2 };
/// <summary>64-byte serialization: E_A1‖E_A2 (each a 32-byte compressed Ristretto point).</summary>
public byte[] Serialize()
{
var b = new byte[64];
Array.Copy(EA1.Encode(), 0, b, 0, 32);
Array.Copy(EA2.Encode(), 0, b, 32, 32);
return b;
}
public static AttributeCiphertext Deserialize(ReadOnlySpan<byte> bytes64)
{
if (bytes64.Length != 64) throw new ArgumentException("ciphertext must be 64 bytes");
Ristretto255 ea1 = Ristretto255.Decode(bytes64[..32]) ?? throw new ArgumentException("bad E_A1");
Ristretto255 ea2 = Ristretto255.Decode(bytes64[32..64]) ?? throw new ArgumentException("bad E_A2");
return new AttributeCiphertext(ea1, ea2);
}
}
/// <summary>
/// A key for encrypting one kind of attribute (a domain). The private key is (a1, a2); the public key is
/// A = a1·G_a1 + a2·G_a2. Different domains use different generator points so ciphertexts can't be confused.
/// </summary>
public sealed class AttributeKeyPair
{
public Scalar25519 A1 { get; }
public Scalar25519 A2 { get; }
public Ristretto255 PublicKey { get; } // A
private AttributeKeyPair(Scalar25519 a1, Scalar25519 a2, Ristretto255 publicKey)
{
A1 = a1; A2 = a2; PublicKey = publicKey;
}
/// <summary>Derives a deterministic key pair from the SHO state and the domain's generator points.</summary>
public static AttributeKeyPair DeriveFrom(ShoHmacSha256 sho, Ristretto255 ga1, Ristretto255 ga2)
{
Scalar25519 a1 = Scalar25519.FromBytesModOrderWide(sho.SqueezeAndRatchet(64));
Scalar25519 a2 = Scalar25519.FromBytesModOrderWide(sho.SqueezeAndRatchet(64));
Ristretto255 a = Ristretto255.Add(ga1.Multiply(a1), ga2.Multiply(a2));
return new AttributeKeyPair(a1, a2, a);
}
public static AttributeKeyPair FromScalars(Scalar25519 a1, Scalar25519 a2, Ristretto255 ga1, Ristretto255 ga2)
=> new(a1, a2, Ristretto255.Add(ga1.Multiply(a1), ga2.Multiply(a2)));
/// <summary>Encrypts an attribute (M1, M2): E_A1 = a1·M1; E_A2 = a2·E_A1 + M2.</summary>
public AttributeCiphertext Encrypt(Ristretto255 m1, Ristretto255 m2)
{
Ristretto255 ea1 = m1.Multiply(A1);
Ristretto255 ea2 = Ristretto255.Add(ea1.Multiply(A2), m2);
return new AttributeCiphertext(ea1, ea2);
}
/// <summary>Recovers M2 = E_A2 a2·E_A1. Throws if E_A1 is the basepoint (a1 not actually encrypting).
/// The caller MUST verify the decoded value re-encrypts to E_A1 (decode is otherwise garbage-in/out).</summary>
public Ristretto255 DecryptToSecondPoint(AttributeCiphertext ct)
{
if (ct.EA1.ConstantTimeEquals(Ristretto255.BasePoint))
throw new ZkGroupVerificationException("E_A1 is the basepoint");
Ristretto255 a2EA1 = ct.EA1.Multiply(A2);
return Ristretto255.Add(ct.EA2, Ristretto255.Negate(a2EA1));
}
}
/// <summary>A zkgroup verification failure (a wrong key, forged ciphertext, or invalid proof).</summary>
public sealed class ZkGroupVerificationException : Exception
{
public ZkGroupVerificationException(string message) : base(message) { }
}
@@ -0,0 +1,158 @@
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Wingnal.Protocol.ZkGroup.Curve;
using Wingnal.Protocol.ZkGroup.Poksho;
namespace Wingnal.Protocol.ZkGroup.ZkCredential;
/// <summary>
/// Issuance + presentation proofs for the zkcredential MAC system (Chase-Perrin-Zaverucha §3.2 / §4.1),
/// expressed as poksho <see cref="Statement"/>s (reusing the byte-exact Schnorr engine). The client uses
/// <see cref="IssuanceProofBuilder"/> to verify a server-issued credential and
/// <see cref="PresentationProofBuilder"/> to build the anonymous presentation it sends to the verifying
/// (storage) server. <see cref="PresentationProofVerifier"/> is included for offline round-trip testing.
/// </summary>
public sealed class EncryptionKeyContext
{
public string Id = "";
public Ristretto255 Ga1 = Ristretto255.Identity;
public Ristretto255 Ga2 = Ristretto255.Identity;
public Scalar25519 A1, A2; // present for the prover (KeyPair)
public Ristretto255? PublicKeyA; // the encryption public key A (present when key is "verified")
}
public sealed class IssuanceProof
{
public Credential Credential = null!;
public byte[] PokshoProof = System.Array.Empty<byte>();
/// <summary>bincode: Credential(96) ‖ Vec&lt;u8&gt;(u64le len ‖ proof bytes).</summary>
public byte[] Serialize()
{
var b = new List<byte>(Credential.Serialize());
Span<byte> len = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(len, (ulong)PokshoProof.Length);
b.AddRange(len.ToArray());
b.AddRange(PokshoProof);
return b.ToArray();
}
public static IssuanceProof Deserialize(ReadOnlySpan<byte> bytes)
{
if (bytes.Length < 96 + 8) throw new ArgumentException("IssuanceProof too short");
var credential = Credential.Deserialize(bytes[..96]);
ulong len = BinaryPrimitives.ReadUInt64LittleEndian(bytes.Slice(96, 8));
if (96 + 8 + (int)len != bytes.Length) throw new ArgumentException("IssuanceProof length mismatch");
return new IssuanceProof { Credential = credential, PokshoProof = bytes.Slice(104, (int)len).ToArray() };
}
}
public sealed class IssuanceProofBuilder
{
private readonly ShoHmacSha256 _publicAttrs;
private readonly byte[] _message;
private readonly List<Ristretto255> _attrPoints = new() { Ristretto255.Identity }; // [0] reserved for public
public IssuanceProofBuilder(byte[] label, byte[]? message = null)
{
_publicAttrs = new ShoHmacSha256(label);
_message = message ?? System.Array.Empty<byte>();
}
public IssuanceProofBuilder AddPublicAttributeU64(ulong value)
{
Span<byte> be = stackalloc byte[8];
BinaryPrimitives.WriteUInt64BigEndian(be, value);
_publicAttrs.AbsorbAndRatchet(be); // ratchet() after is a no-op (already ratcheted)
return this;
}
public IssuanceProofBuilder AddAttribute(Ristretto255[] points)
{
_attrPoints.AddRange(points);
if (_attrPoints.Count > CredentialSystem.NumSupportedAttrs)
throw new ArgumentException("too many attribute points");
return this;
}
private void FinalizePublicAttrs() => _attrPoints[0] = _publicAttrs.GetPoint();
private Statement BuildStatement()
{
var st = new Statement();
st.Add("C_W", ("w", "G_w"), ("wprime", "G_wprime"));
var gvi = new (string, string)[]
{
("x0", "G_x0"), ("x1", "G_x1"),
("y0", "G_y0"), ("y1", "G_y1"), ("y2", "G_y2"), ("y3", "G_y3"),
("y4", "G_y4"), ("y5", "G_y5"), ("y6", "G_y6"),
};
st.Add("G_V-I", gvi[..(2 + _attrPoints.Count)]);
var vt = new (string, string)[]
{
("w", "G_w"), ("x0", "U"), ("x1", "tU"),
("y0", "M0"), ("y1", "M1"), ("y2", "M2"), ("y3", "M3"),
("y4", "M4"), ("y5", "M5"), ("y6", "M6"),
};
st.Add("V", vt[..(3 + _attrPoints.Count)]);
return st;
}
private Dictionary<string, Ristretto255> PointArgs(CredentialPublicKey key, Credential credential)
{
var s = CredentialSystem.SystemParams.Hardcoded;
var p = new Dictionary<string, Ristretto255>
{
["C_W"] = key.CW,
["G_w"] = s.GW,
["G_wprime"] = s.GWprime,
["G_V-I"] = Ristretto255.Add(s.GV, Ristretto255.Negate(key.IFor(_attrPoints.Count))),
["G_x0"] = s.GX0,
["G_x1"] = s.GX1,
["V"] = credential.V,
["U"] = credential.U,
["tU"] = credential.U.Multiply(credential.T),
};
string[] gy = { "G_y0", "G_y1", "G_y2", "G_y3", "G_y4", "G_y5", "G_y6" };
for (int i = 0; i < _attrPoints.Count; i++) p[gy[i]] = s.GY[i];
string[] mn = { "M0", "M1", "M2", "M3", "M4", "M5", "M6" };
for (int i = 0; i < _attrPoints.Count; i++) p[mn[i]] = _attrPoints[i];
return p;
}
/// <summary>Verifies a server-issued credential, returning it on success.</summary>
public Credential Verify(CredentialPublicKey publicKey, IssuanceProof proof)
{
FinalizePublicAttrs();
Dictionary<string, Ristretto255> points = PointArgs(publicKey, proof.Credential);
if (!BuildStatement().VerifyProof(proof.PokshoProof, points, _message))
throw new ZkGroupVerificationException("issuance proof did not verify");
return proof.Credential;
}
/// <summary>Issues a credential (server side; used for offline tests).</summary>
public IssuanceProof Issue(CredentialKeyPair keyPair, byte[] randomness)
{
FinalizePublicAttrs();
var sho = new ShoHmacSha256(Encoding.ASCII.GetBytes("Signal_ZKCredential_Issuance_20230410"));
sho.AbsorbAndRatchet(randomness);
Credential credential = keyPair.Private.CredentialCore(_attrPoints.ToArray(), sho);
var scalars = new Dictionary<string, Scalar25519>
{
["w"] = keyPair.Private.W,
["wprime"] = keyPair.Private.Wprime,
["x0"] = keyPair.Private.X0,
["x1"] = keyPair.Private.X1,
};
string[] yn = { "y0", "y1", "y2", "y3", "y4", "y5", "y6" };
for (int i = 0; i < _attrPoints.Count; i++) scalars[yn[i]] = keyPair.Private.Y[i];
Dictionary<string, Ristretto255> points = PointArgs(keyPair.Public, credential);
byte[] poksho = BuildStatement().Prove(scalars, points, _message, sho.SqueezeAndRatchet(32));
return new IssuanceProof { Credential = credential, PokshoProof = poksho };
}
}
@@ -0,0 +1,185 @@
using System.Text;
using Wingnal.Protocol.ZkGroup.Curve;
using Wingnal.Protocol.ZkGroup.Poksho;
namespace Wingnal.Protocol.ZkGroup.ZkCredential;
/// <summary>
/// Port of libsignal's <c>zkcredential::credentials</c> — the algebraic-MAC credential system
/// (Chase-Perrin-Zaverucha §3.1) that AuthCredential/ProfileKeyCredential are built on. Supports up to
/// <see cref="NumSupportedAttrs"/> attribute points. The shared <see cref="SystemParams"/> generators are
/// derived via <see cref="ShoSha256"/> and gated against libsignal's hardcoded serialization.
/// </summary>
public static class CredentialSystem
{
public const int NumSupportedAttrs = 7; // 1 aggregate public + 3 two-point private attributes
public sealed class SystemParams
{
public Ristretto255 GW, GWprime, GX0, GX1, GV, GZ;
public Ristretto255[] GY = new Ristretto255[NumSupportedAttrs];
public static readonly SystemParams Hardcoded = Generate();
public static SystemParams Generate()
{
var sho = new ShoSha256(Encoding.ASCII.GetBytes(
"Signal_ZKCredential_ConstantSystemParams_generate_20230410"));
var p = new SystemParams
{
GW = sho.GetPoint(),
GWprime = sho.GetPoint(),
GX0 = sho.GetPoint(),
GX1 = sho.GetPoint(),
GV = sho.GetPoint(),
GZ = sho.GetPoint(),
};
for (int i = 0; i < NumSupportedAttrs; i++) p.GY[i] = sho.GetPoint();
return p;
}
/// <summary>bincode serialization: G_w‖G_wprime‖G_x0‖G_x1‖G_V‖G_z‖G_y[0..7] (13 × 32 = 416 bytes).</summary>
public byte[] Serialize()
{
var b = new byte[13 * 32];
int o = 0;
foreach (Ristretto255 p in new[] { GW, GWprime, GX0, GX1, GV, GZ })
{ Array.Copy(p.Encode(), 0, b, o, 32); o += 32; }
foreach (Ristretto255 p in GY) { Array.Copy(p.Encode(), 0, b, o, 32); o += 32; }
return b;
}
}
}
/// <summary>A credential: the MAC (t, U, V) issued by the server over a set of attributes.</summary>
public sealed class Credential
{
public Scalar25519 T;
public Ristretto255 U;
public Ristretto255 V;
public Credential(Scalar25519 t, Ristretto255 u, Ristretto255 v) { T = t; U = u; V = v; }
/// <summary>bincode: t(32)‖U(32)‖V(32) = 96 bytes.</summary>
public byte[] Serialize()
{
var b = new byte[96];
Array.Copy(T.ToBytes(), 0, b, 0, 32);
Array.Copy(U.Encode(), 0, b, 32, 32);
Array.Copy(V.Encode(), 0, b, 64, 32);
return b;
}
public static Credential Deserialize(ReadOnlySpan<byte> b)
{
if (b.Length != 96) throw new ArgumentException("credential must be 96 bytes");
Scalar25519 t = Scalar25519.FromCanonicalBytes(b[..32]) ?? throw new ArgumentException("bad t");
Ristretto255 u = Ristretto255.Decode(b[32..64]) ?? throw new ArgumentException("bad U");
Ristretto255 v = Ristretto255.Decode(b[64..96]) ?? throw new ArgumentException("bad V");
return new Credential(t, u, v);
}
}
/// <summary>The server's private credential key (only needed locally for tests / a verifying server).</summary>
public sealed class CredentialPrivateKey
{
public Scalar25519 W, Wprime, X0, X1;
public Ristretto255 BigW;
public Scalar25519[] Y = new Scalar25519[CredentialSystem.NumSupportedAttrs];
public static CredentialPrivateKey Generate(byte[] randomness)
{
var sho = new ShoHmacSha256(Encoding.ASCII.GetBytes(
"Signal_ZKCredential_CredentialPrivateKey_generate_20230410"));
sho.AbsorbAndRatchet(randomness);
var system = CredentialSystem.SystemParams.Hardcoded;
var k = new CredentialPrivateKey();
k.W = sho.GetScalar();
k.BigW = system.GW.Multiply(k.W);
k.Wprime = sho.GetScalar();
k.X0 = sho.GetScalar();
k.X1 = sho.GetScalar();
for (int i = 0; i < CredentialSystem.NumSupportedAttrs; i++) k.Y[i] = sho.GetScalar();
return k;
}
/// <summary>Produces the MAC over the attribute points (Chase-Perrin-Zaverucha §3.1).</summary>
public Credential CredentialCore(Ristretto255[] m, ShoHmacSha256 sho)
{
if (m.Length > CredentialSystem.NumSupportedAttrs) throw new ArgumentException("too many attributes");
Scalar25519 t = sho.GetScalar();
Ristretto255 u = sho.GetPoint();
// V = W + (x0 + x1·t)·U + Σ y_i·M_i
Scalar25519 coeff = Scalar25519.Add(X0, Scalar25519.Mul(X1, t));
Ristretto255 v = Ristretto255.Add(BigW, u.Multiply(coeff));
for (int i = 0; i < m.Length; i++) v = Ristretto255.Add(v, m[i].Multiply(Y[i]));
return new Credential(t, u, v);
}
}
/// <summary>The server's public credential key the client uses to receive + present credentials.</summary>
public sealed class CredentialPublicKey
{
public Ristretto255 CW;
public Ristretto255[] I = new Ristretto255[CredentialSystem.NumSupportedAttrs - 1]; // I_2 .. I_7
/// <summary>I for a credential with <paramref name="numAttrs"/> attribute points (≥2).</summary>
public Ristretto255 IFor(int numAttrs) => I[numAttrs - 2];
public static CredentialPublicKey FromPrivate(CredentialPrivateKey priv)
{
var system = CredentialSystem.SystemParams.Hardcoded;
var pub = new CredentialPublicKey
{
CW = Ristretto255.Add(priv.BigW, system.GWprime.Multiply(priv.Wprime)),
};
// I_i = G_V - x0·G_x0 - x1·G_x1 - Σ_{j≤i} y_j·G_y_j
Ristretto255 ii = system.GV;
ii = Ristretto255.Add(ii, Ristretto255.Negate(system.GX0.Multiply(priv.X0)));
ii = Ristretto255.Add(ii, Ristretto255.Negate(system.GX1.Multiply(priv.X1)));
ii = Ristretto255.Add(ii, Ristretto255.Negate(system.GY[0].Multiply(priv.Y[0])));
for (int n = 1; n < CredentialSystem.NumSupportedAttrs; n++)
{
ii = Ristretto255.Add(ii, Ristretto255.Negate(system.GY[n].Multiply(priv.Y[n])));
pub.I[n - 1] = ii;
}
return pub;
}
/// <summary>bincode: C_W(32)‖I[6]·32 = 224 bytes.</summary>
public byte[] Serialize()
{
var b = new byte[32 + 32 * (CredentialSystem.NumSupportedAttrs - 1)];
Array.Copy(CW.Encode(), 0, b, 0, 32);
for (int i = 0; i < I.Length; i++) Array.Copy(I[i].Encode(), 0, b, 32 + 32 * i, 32);
return b;
}
public static CredentialPublicKey Deserialize(ReadOnlySpan<byte> b)
{
int expected = 32 + 32 * (CredentialSystem.NumSupportedAttrs - 1);
if (b.Length != expected) throw new ArgumentException("bad CredentialPublicKey length");
var pub = new CredentialPublicKey
{
CW = Ristretto255.Decode(b[..32]) ?? throw new ArgumentException("bad C_W"),
};
for (int i = 0; i < pub.I.Length; i++)
pub.I[i] = Ristretto255.Decode(b.Slice(32 + 32 * i, 32)) ?? throw new ArgumentException("bad I");
return pub;
}
}
/// <summary>The server's credential key pair (private + derived public).</summary>
public sealed class CredentialKeyPair
{
public CredentialPrivateKey Private { get; }
public CredentialPublicKey Public { get; }
private CredentialKeyPair(CredentialPrivateKey priv, CredentialPublicKey pub) { Private = priv; Public = pub; }
public static CredentialKeyPair Generate(byte[] randomness)
{
var priv = CredentialPrivateKey.Generate(randomness);
return new CredentialKeyPair(priv, CredentialPublicKey.FromPrivate(priv));
}
}
@@ -0,0 +1,278 @@
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Wingnal.Protocol.ZkGroup.Curve;
using Wingnal.Protocol.ZkGroup.Poksho;
namespace Wingnal.Protocol.ZkGroup.ZkCredential;
/// <summary>A credential presentation proof (Chase-Perrin-Zaverucha §3.2/§4.1): the commitments plus the
/// poksho proof. Serialized with bincode (fixint, little-endian; Vec = u64 length prefix + elements).</summary>
public sealed class PresentationProof
{
public Ristretto255 Cx0 = null!, Cx1 = null!, Cv = null!;
public Ristretto255[] Cy = System.Array.Empty<Ristretto255>();
public byte[] PokshoProof = System.Array.Empty<byte>();
public byte[] Serialize()
{
var ms = new List<byte>();
ms.AddRange(Cx0.Encode());
ms.AddRange(Cx1.Encode());
ms.AddRange(Cv.Encode());
AddU64Le(ms, (ulong)Cy.Length);
foreach (Ristretto255 p in Cy) ms.AddRange(p.Encode());
AddU64Le(ms, (ulong)PokshoProof.Length);
ms.AddRange(PokshoProof);
return ms.ToArray();
}
private static void AddU64Le(List<byte> dst, ulong v)
{
Span<byte> b = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(b, v);
dst.AddRange(b.ToArray());
}
}
internal struct AttrRef { public int? KeyIndex; public int First; public int Second; }
/// <summary>Builds a credential presentation (the anonymous proof sent to the verifying/storage server).</summary>
public sealed class PresentationProofBuilder
{
private readonly byte[] _message;
private readonly List<EncryptionKeyContext> _keys = new();
private readonly List<AttrRef> _attrs = new();
private readonly List<Ristretto255> _attrPoints = new() { Ristretto255.Identity };
public PresentationProofBuilder(byte[] label, byte[]? message = null)
{
_ = label; // label is ignored on the prover side (public attrs are server-provided)
_message = message ?? System.Array.Empty<byte>();
}
public PresentationProofBuilder AddAttribute(Ristretto255[] points, EncryptionKeyContext key)
{
int first = _attrPoints.Count;
_attrPoints.AddRange(points);
if (_attrPoints.Count > CredentialSystem.NumSupportedAttrs)
throw new ArgumentException("too many attribute points");
int keyIndex = _keys.FindIndex(k => k.Id == key.Id);
if (keyIndex < 0) { keyIndex = _keys.Count; _keys.Add(key); }
_attrs.Add(new AttrRef { KeyIndex = keyIndex, First = first, Second = first + points.Length - 1 });
return this;
}
public PresentationProof Present(CredentialPublicKey publicKey, Credential credential, byte[] randomness)
{
var s = CredentialSystem.SystemParams.Hardcoded;
var sho = new ShoHmacSha256(Encoding.ASCII.GetBytes("Signal_ZKCredential_Presentation_20230410"));
sho.AbsorbAndRatchet(randomness);
Scalar25519 z = sho.GetScalar();
var cy = new Ristretto255[_attrPoints.Count];
for (int i = 0; i < _attrPoints.Count; i++)
cy[i] = Ristretto255.Add(s.GY[i].Multiply(z), _attrPoints[i]);
Ristretto255 cx0 = Ristretto255.Add(s.GX0.Multiply(z), credential.U);
Ristretto255 cv = Ristretto255.Add(s.GV.Multiply(z), credential.V);
Ristretto255 cx1 = Ristretto255.Add(s.GX1.Multiply(z), credential.U.Multiply(credential.T));
Scalar25519 z0 = Scalar25519.Negate(Scalar25519.Mul(z, credential.T));
Ristretto255 ii = publicKey.IFor(_attrPoints.Count);
Ristretto255 bigZ = ii.Multiply(z);
var scalars = new Dictionary<string, Scalar25519> { ["z"] = z, ["t"] = credential.T, ["z0"] = z0 };
foreach (EncryptionKeyContext k in _keys)
{
scalars[$"a1_{k.Id}"] = k.A1;
scalars[$"a2_{k.Id}"] = k.A2;
scalars[$"z1_{k.Id}"] = Scalar25519.Negate(Scalar25519.Mul(z, k.A1));
}
Dictionary<string, Ristretto255> points = PrepareNonAttrPoints(ii, cx0, cx1, cy);
points["Z"] = bigZ;
foreach (AttrRef attr in _attrs)
{
points[$"C_y{attr.First}"] = cy[attr.First];
if (attr.KeyIndex is { } ki)
{
EncryptionKeyContext k = _keys[ki];
Ristretto255 eA1 = _attrPoints[attr.First].Multiply(k.A1);
Ristretto255 eA2 = Ristretto255.Add(eA1.Multiply(k.A2), _attrPoints[attr.Second]);
points[$"E_A{attr.First}"] = eA1;
points[$"-E_A{attr.First}"] = Ristretto255.Negate(eA1);
points[$"C_y{attr.Second}-E_A{attr.Second}"] = Ristretto255.Add(cy[attr.Second], Ristretto255.Negate(eA2));
}
}
byte[] poksho = BuildStatement(_keys, _attrs).Prove(scalars, points, _message, sho.SqueezeAndRatchet(32));
return new PresentationProof { Cx0 = cx0, Cx1 = cx1, Cv = cv, Cy = cy, PokshoProof = poksho };
}
private Dictionary<string, Ristretto255> PrepareNonAttrPoints(
Ristretto255 ii, Ristretto255 cx0, Ristretto255 cx1, Ristretto255[] cy)
{
var s = CredentialSystem.SystemParams.Hardcoded;
var p = new Dictionary<string, Ristretto255>
{
["I"] = ii, ["C_x0"] = cx0, ["C_x1"] = cx1, ["G_x0"] = s.GX0, ["G_x1"] = s.GX1,
};
if (_keys.Count > 0)
{
p["0"] = Ristretto255.Identity;
Ristretto255 sumA = Ristretto255.Identity;
bool any = false;
foreach (EncryptionKeyContext k in _keys)
{
if (k.PublicKeyA is { } a)
{
p[$"G_a1_{k.Id}"] = k.Ga1;
p[$"G_a2_{k.Id}"] = k.Ga2;
sumA = Ristretto255.Add(sumA, a);
any = true;
}
}
if (any) p["sum(A)"] = sumA;
}
string[] gy = { "G_y0", "G_y1", "G_y2", "G_y3", "G_y4", "G_y5", "G_y6" };
for (int i = 0; i < _attrPoints.Count; i++) p[gy[i]] = s.GY[i];
p["C_y0"] = cy[0];
return p;
}
internal static Statement BuildStatement(List<EncryptionKeyContext> keys, List<AttrRef> attrs)
{
var st = new Statement();
st.Add("Z", ("z", "I"));
st.Add("C_x1", ("t", "C_x0"), ("z0", "G_x0"), ("z", "G_x1"));
var sumTerms = new List<(string, string)>();
foreach (EncryptionKeyContext k in keys)
{
st.Add("0", ($"z1_{k.Id}", "I"), ($"a1_{k.Id}", "Z"));
if (k.PublicKeyA is not null)
{
sumTerms.Add(($"a1_{k.Id}", $"G_a1_{k.Id}"));
sumTerms.Add(($"a2_{k.Id}", $"G_a2_{k.Id}"));
}
}
if (sumTerms.Count > 0) st.Add("sum(A)", sumTerms.ToArray());
foreach (AttrRef attr in attrs)
{
if (attr.KeyIndex is { } ki)
{
string id = keys[ki].Id;
st.Add($"E_A{attr.First}", ($"a1_{id}", $"C_y{attr.First}"), ($"z1_{id}", $"G_y{attr.First}"));
st.Add($"C_y{attr.Second}-E_A{attr.Second}",
("z", $"G_y{attr.Second}"), ($"a2_{id}", $"-E_A{attr.First}"));
}
else
{
st.Add($"C_y{attr.First}", ("z", $"G_y{attr.First}"));
}
}
st.Add("C_y0", ("z", "G_y0"));
return st;
}
}
/// <summary>Verifies a presentation (the verifying-server side; used here for offline round-trip testing).</summary>
public sealed class PresentationProofVerifier
{
private readonly ShoHmacSha256 _publicAttrs;
private readonly byte[] _message;
private readonly List<EncryptionKeyContext> _keys = new();
private readonly List<AttrRef> _attrs = new();
private readonly List<Ristretto255> _attrPoints = new() { Ristretto255.Identity };
public PresentationProofVerifier(byte[] label, byte[]? message = null)
{
_publicAttrs = new ShoHmacSha256(label);
_message = message ?? System.Array.Empty<byte>();
}
public PresentationProofVerifier AddPublicAttributeU64(ulong value)
{
Span<byte> be = stackalloc byte[8];
BinaryPrimitives.WriteUInt64BigEndian(be, value);
_publicAttrs.AbsorbAndRatchet(be);
return this;
}
/// <summary>Adds an encrypted attribute (the ciphertext points) + the public encryption key context.</summary>
public PresentationProofVerifier AddAttribute(Ristretto255[] ciphertextPoints, EncryptionKeyContext key)
{
int first = _attrPoints.Count;
_attrPoints.AddRange(ciphertextPoints);
int keyIndex = _keys.FindIndex(k => k.Id == key.Id);
if (keyIndex < 0) { keyIndex = _keys.Count; _keys.Add(key); }
_attrs.Add(new AttrRef { KeyIndex = keyIndex, First = first, Second = first + ciphertextPoints.Length - 1 });
return this;
}
public bool Verify(CredentialKeyPair keyPair, PresentationProof proof)
{
_attrPoints[0] = _publicAttrs.GetPoint();
if (proof.Cy.Length != _attrPoints.Count) return false;
CredentialPrivateKey priv = keyPair.Private;
// Z = C_V - W - x0·C_x0 - x1·C_x1 - Σ y_i·C_y_i - y0·M0
Ristretto255 z = proof.Cv;
z = Ristretto255.Add(z, Ristretto255.Negate(priv.BigW));
z = Ristretto255.Add(z, Ristretto255.Negate(proof.Cx0.Multiply(priv.X0)));
z = Ristretto255.Add(z, Ristretto255.Negate(proof.Cx1.Multiply(priv.X1)));
for (int i = 0; i < proof.Cy.Length; i++)
z = Ristretto255.Add(z, Ristretto255.Negate(proof.Cy[i].Multiply(priv.Y[i])));
z = Ristretto255.Add(z, Ristretto255.Negate(_attrPoints[0].Multiply(priv.Y[0])));
Ristretto255 ii = keyPair.Public.IFor(_attrPoints.Count);
Dictionary<string, Ristretto255> points = PrepareNonAttrPoints(ii, proof.Cx0, proof.Cx1, proof.Cy);
foreach (AttrRef attr in _attrs)
{
points[$"C_y{attr.First}"] = proof.Cy[attr.First];
if (attr.KeyIndex is not null)
{
points[$"E_A{attr.First}"] = _attrPoints[attr.First];
points[$"-E_A{attr.First}"] = Ristretto255.Negate(_attrPoints[attr.First]);
points[$"C_y{attr.Second}-E_A{attr.Second}"] =
Ristretto255.Add(proof.Cy[attr.Second], Ristretto255.Negate(_attrPoints[attr.Second]));
}
else
{
z = Ristretto255.Add(z, Ristretto255.Negate(_attrPoints[attr.First].Multiply(priv.Y[attr.First])));
}
}
points["Z"] = z;
return PresentationProofBuilder.BuildStatement(_keys, _attrs).VerifyProof(proof.PokshoProof, points, _message);
}
private Dictionary<string, Ristretto255> PrepareNonAttrPoints(
Ristretto255 ii, Ristretto255 cx0, Ristretto255 cx1, Ristretto255[] cy)
{
var s = CredentialSystem.SystemParams.Hardcoded;
var p = new Dictionary<string, Ristretto255>
{
["I"] = ii, ["C_x0"] = cx0, ["C_x1"] = cx1, ["G_x0"] = s.GX0, ["G_x1"] = s.GX1,
};
if (_keys.Count > 0)
{
p["0"] = Ristretto255.Identity;
Ristretto255 sumA = Ristretto255.Identity;
bool any = false;
foreach (EncryptionKeyContext k in _keys)
{
if (k.PublicKeyA is { } a)
{
p[$"G_a1_{k.Id}"] = k.Ga1; p[$"G_a2_{k.Id}"] = k.Ga2;
sumA = Ristretto255.Add(sumA, a); any = true;
}
}
if (any) p["sum(A)"] = sumA;
}
string[] gy = { "G_y0", "G_y1", "G_y2", "G_y3", "G_y4", "G_y5", "G_y6" };
for (int i = 0; i < _attrPoints.Count; i++) p[gy[i]] = s.GY[i];
p["C_y0"] = cy[0];
return p;
}
}