Skip to main content

zkgroup/api/
zk_credential_key.rs

1//
2// Copyright 2026 Signal Messenger, LLC.
3// SPDX-License-Identifier: AGPL-3.0-only
4//
5
6//! Long-term Ristretto key pair owned by an account, used as a binding identity
7//! across ZK credentials issued to that account (currently
8//! [`crate::avatars::AvatarUploadCredential`]).
9//!
10//! The secret key is a pair of scalars `(a1, a2)`, generated deterministically from a 32 bytes
11//! seed; the public key is `A = a1*G_a1 + a2*G_a2`.
12//! Distinct from the account's curve25519 identity key. The public key is a wire
13//! type stored by the server; the secret key is a wire type that must be synced
14//! to linked devices.
15//!
16//! Internally this wraps [`zkcredential::attributes::KeyPair`] to reuse its `(a1, a2)` / `A`
17//! structure and domain-separated generators, but it deliberately does **not** expose that type's
18//! CPZ `encrypt`/`decrypt` methods: this key is used only for its scalar/point structure, never to
19//! encrypt attributes.
20
21// We use upper-case variable names for curve points by convention.
22#![allow(non_snake_case)]
23
24use std::sync::OnceLock;
25
26use curve25519_dalek::ristretto::RistrettoPoint;
27use curve25519_dalek::scalar::Scalar;
28use partial_default::PartialDefault;
29use poksho::ShoApi;
30use serde::{Deserialize, Serialize};
31use zkcredential::attributes::{Domain, KeyPair, PublicKey, derive_default_generator_points};
32
33use crate::RandomnessBytes;
34use crate::common::serialization::ReservedByte;
35
36/// Domain for the account ZK credential key.
37///
38/// `G_a()` supplies the two independent generators `(G_a1, G_a2)` for the
39/// public key `A = a1*G_a1 + a2*G_a2`. The `Attribute` type is required by the
40/// trait but isn't really used here: this key never performs CPZ attribute
41/// encryption.
42pub struct ZkCredentialKeyDomain;
43
44impl Domain for ZkCredentialKeyDomain {
45    type Attribute = [RistrettoPoint; 2];
46
47    const ID: &'static str = "Signal_ZkCredentialKey_20260602";
48
49    fn G_a() -> [RistrettoPoint; 2] {
50        static STORAGE: OnceLock<[RistrettoPoint; 2]> = OnceLock::new();
51        *derive_default_generator_points::<Self>(&STORAGE)
52    }
53}
54
55#[derive(Clone, Serialize, Deserialize, PartialDefault)]
56pub struct ZkCredentialKeyPair {
57    reserved: ReservedByte,
58    inner: KeyPair<ZkCredentialKeyDomain>,
59}
60
61impl ZkCredentialKeyPair {
62    pub fn generate(randomness: RandomnessBytes) -> Self {
63        let mut sho = poksho::ShoHmacSha256::new(b"20260520_Signal_ZkCredentialKeyPair_Generate");
64        sho.absorb_and_ratchet(&randomness);
65        Self {
66            reserved: Default::default(),
67            inner: KeyPair::derive_from(&mut sho),
68        }
69    }
70
71    pub fn public_key(&self) -> ZkCredentialPublicKey {
72        ZkCredentialPublicKey {
73            reserved: Default::default(),
74            inner: self.inner.public_key,
75        }
76    }
77
78    /// The two secret scalars `(a1, a2)`.
79    pub(crate) fn secrets(&self) -> (Scalar, Scalar) {
80        (self.inner.a1, self.inner.a2)
81    }
82}
83
84/// The public half of a [`ZkCredentialKeyPair`].
85///
86/// Serialized wire format: reserved byte + 32-byte compressed Ristretto point (`A`).
87#[derive(Clone, Copy, Serialize, Deserialize, PartialDefault)]
88pub struct ZkCredentialPublicKey {
89    reserved: ReservedByte,
90    inner: PublicKey<ZkCredentialKeyDomain>,
91}
92
93impl ZkCredentialPublicKey {
94    pub(crate) fn point(&self) -> RistrettoPoint {
95        self.inner.A
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::RANDOMNESS_LEN;
103
104    #[test]
105    fn generate_is_deterministic() {
106        let r: RandomnessBytes = [0x7Au8; RANDOMNESS_LEN];
107        let a = ZkCredentialKeyPair::generate(r);
108        let b = ZkCredentialKeyPair::generate(r);
109        assert_eq!(a.secrets(), b.secrets());
110        assert_eq!(a.public_key().point(), b.public_key().point());
111    }
112
113    #[test]
114    fn roundtrip_keypair() {
115        let r: RandomnessBytes = [0x11u8; RANDOMNESS_LEN];
116        let kp = ZkCredentialKeyPair::generate(r);
117        let bytes = crate::serialize(&kp);
118        let parsed: ZkCredentialKeyPair = crate::deserialize(&bytes).expect("roundtrip");
119        assert_eq!(kp.secrets(), parsed.secrets());
120        assert_eq!(kp.public_key().point(), parsed.public_key().point());
121    }
122
123    #[test]
124    fn roundtrip_public_key() {
125        let r: RandomnessBytes = [0x22u8; RANDOMNESS_LEN];
126        let pk = ZkCredentialKeyPair::generate(r).public_key();
127        let bytes = crate::serialize(&pk);
128        let parsed: ZkCredentialPublicKey = crate::deserialize(&bytes).expect("roundtrip");
129        assert_eq!(pk.point(), parsed.point());
130    }
131
132    #[test]
133    fn public_key_derives_from_secrets() {
134        let r: RandomnessBytes = [0x33u8; RANDOMNESS_LEN];
135        let kp = ZkCredentialKeyPair::generate(r);
136        let (a1, a2) = kp.secrets();
137        let [G_a1, G_a2] = ZkCredentialKeyDomain::G_a();
138        assert_eq!(a1 * G_a1 + a2 * G_a2, kp.public_key().point());
139    }
140}