Add project files.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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..]));
|
||||
}
|
||||
}
|
||||
@@ -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^((p−5)/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();
|
||||
}
|
||||
@@ -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.1–4.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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<u8>(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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user