feat(fof-layer1): schema + storage API + vouch-grant crypto primitives
Lands the foundational pieces for FoF Layer 1 (vouch primitive) per
docs/fof-spec/layer-1-vouch-primitive.md:
Schema (init_tables, CREATE TABLE IF NOT EXISTS — safe for upgrade and
fresh installs):
- vouch_keys_own: per-persona V_me history, append-only on rotation
- vouch_keys_received: per-persona inbound keyring, multi-epoch
- vouch_bio_scan_cache: short-circuits unchanged-bio re-scans
- own_vouch_targets: author-local, never on wire, drives batch assembly
Storage API: insert/list/lookup for all four tables, including
current_own_vouch_key, list_received_vouch_keys, list_vouchers_for,
record_bio_scan_result, upsert/revoke_vouch_target.
Crypto: HPKE-style seal_vouch_grant / open_vouch_grant using existing
ed25519 → X25519 derivation. Per-batch ephemeral X25519 keypair via
generate_vouch_batch_ephemeral. Wrapper is 48B (32B sealed V_me + 16B
AEAD tag). Recipient-free derivation context per spec — info string
is "itsgoin/vouch-grant/v1/{key|nonce}/<bio_post_id>". 3 unit tests
cover roundtrip + wrong-post-id + random-bytes-as-dummy.
No behavior change yet; nothing wired in. Layer 1 wire types, persona
auto-gen, publish/scan paths follow in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d7ce2f734c
commit
8a53d83306
2 changed files with 534 additions and 0 deletions
|
|
@ -12,6 +12,12 @@ use crate::types::{GroupEpoch, GroupId, GroupMemberKey, NodeId, PostId, WrappedK
|
|||
|
||||
const CEK_WRAP_CONTEXT: &str = "itsgoin/cek-wrap/v1";
|
||||
|
||||
/// FoF Layer 1: vouch-grant HPKE-style wrapper construction.
|
||||
/// HKDF/derive_key info MUST be recipient-free (key privacy).
|
||||
/// `bio_post_id` ties the wrapper to the publishing bio post.
|
||||
const VOUCH_GRANT_KEY_CONTEXT: &str = "itsgoin/vouch-grant/v1/key";
|
||||
const VOUCH_GRANT_NONCE_CONTEXT: &str = "itsgoin/vouch-grant/v1/nonce";
|
||||
|
||||
/// Convert an ed25519 seed (32 bytes from identity.key) to X25519 private scalar bytes.
|
||||
pub fn ed25519_seed_to_x25519_private(seed: &[u8; 32]) -> [u8; 32] {
|
||||
let signing_key = SigningKey::from_bytes(seed);
|
||||
|
|
@ -193,6 +199,108 @@ pub fn unwrap_group_cek(
|
|||
Ok(cek)
|
||||
}
|
||||
|
||||
// --- FoF Layer 1: vouch-grant HPKE-style seal/open ---
|
||||
//
|
||||
// Per the FoF spec (docs/fof-spec/layer-1-vouch-primitive.md), a voucher
|
||||
// publishes anonymous per-recipient wrappers inside their bio post. Each
|
||||
// wrapper carries `V_me` (the voucher's symmetric key) sealed under a
|
||||
// shared secret derived from ECDH between a per-batch ephemeral X25519
|
||||
// keypair and the recipient's persona X25519 key.
|
||||
//
|
||||
// Recipient anonymity ("key privacy") is preserved because:
|
||||
// 1. Wrappers carry no recipient identifier.
|
||||
// 2. The KDF info string is recipient-free (only the post_id appears).
|
||||
// 3. All wrappers in a batch share the same ephemeral pubkey.
|
||||
//
|
||||
// Wire shape: 48 bytes per wrapper (32B sealed V_me + 16B AEAD tag).
|
||||
// One 32B ephemeral pubkey shared across all wrappers in the batch.
|
||||
|
||||
/// Generate a fresh ephemeral X25519 keypair for a vouch-grant batch.
|
||||
/// Returns `(eph_priv_scalar, eph_pub)` in X25519 byte form. Reuses the
|
||||
/// ed25519 → X25519 derivation path that the rest of the codebase uses
|
||||
/// so all X25519 endpoints are produced identically.
|
||||
pub fn generate_vouch_batch_ephemeral() -> ([u8; 32], [u8; 32]) {
|
||||
let mut seed = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut seed);
|
||||
let eph_priv = ed25519_seed_to_x25519_private(&seed);
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let eph_pub = signing_key.verifying_key().to_montgomery().to_bytes();
|
||||
(eph_priv, eph_pub)
|
||||
}
|
||||
|
||||
/// Derive the (wrapping_key, nonce) pair for a vouch-grant wrapper from
|
||||
/// the ECDH shared secret and the publishing bio post's ID.
|
||||
fn derive_vouch_grant_key_nonce(
|
||||
shared_secret: &[u8; 32],
|
||||
bio_post_id: &PostId,
|
||||
) -> ([u8; 32], [u8; 12]) {
|
||||
// Bake bio_post_id into the derivation context. Recipient-free.
|
||||
let key_ctx = format!("{}/{}", VOUCH_GRANT_KEY_CONTEXT, hex_lower(bio_post_id));
|
||||
let nonce_ctx = format!("{}/{}", VOUCH_GRANT_NONCE_CONTEXT, hex_lower(bio_post_id));
|
||||
let wrapping_key = blake3::derive_key(&key_ctx, shared_secret);
|
||||
let nonce_full = blake3::derive_key(&nonce_ctx, shared_secret);
|
||||
let mut nonce = [0u8; 12];
|
||||
nonce.copy_from_slice(&nonce_full[..12]);
|
||||
(wrapping_key, nonce)
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8; 32]) -> String {
|
||||
let mut s = String::with_capacity(64);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Seal `V_me` (32B) under the recipient's X25519 pubkey using the
|
||||
/// batch's ephemeral X25519 private key. Returns the 48-byte wrapper
|
||||
/// `ciphertext(32) || tag(16)`.
|
||||
pub fn seal_vouch_grant(
|
||||
eph_priv: &[u8; 32],
|
||||
recipient_x25519_pub: &[u8; 32],
|
||||
bio_post_id: &PostId,
|
||||
v_me: &[u8; 32],
|
||||
) -> Result<Vec<u8>> {
|
||||
let shared_secret = x25519_dh(eph_priv, recipient_x25519_pub);
|
||||
let (wrapping_key, nonce) = derive_vouch_grant_key_nonce(&shared_secret, bio_post_id);
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
|
||||
.map_err(|e| anyhow::anyhow!("vouch-grant cipher init: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(Nonce::from_slice(&nonce), v_me.as_slice())
|
||||
.map_err(|e| anyhow::anyhow!("vouch-grant seal: {}", e))?;
|
||||
// ChaCha20-Poly1305 output is 32B plaintext + 16B tag = 48B.
|
||||
if ciphertext.len() != 48 {
|
||||
bail!("unexpected vouch-grant wrapper length: {}", ciphertext.len());
|
||||
}
|
||||
Ok(ciphertext)
|
||||
}
|
||||
|
||||
/// Try to open a vouch-grant wrapper using the recipient's X25519 private
|
||||
/// scalar. Returns `Some(V_me)` on success, `None` on AEAD failure (i.e.,
|
||||
/// this wrapper was not addressed to this recipient).
|
||||
pub fn open_vouch_grant(
|
||||
recipient_x25519_priv: &[u8; 32],
|
||||
batch_eph_pub: &[u8; 32],
|
||||
bio_post_id: &PostId,
|
||||
wrapper_ciphertext: &[u8],
|
||||
) -> Option<[u8; 32]> {
|
||||
if wrapper_ciphertext.len() != 48 {
|
||||
return None;
|
||||
}
|
||||
let shared_secret = x25519_dh(recipient_x25519_priv, batch_eph_pub);
|
||||
let (wrapping_key, nonce) = derive_vouch_grant_key_nonce(&shared_secret, bio_post_id);
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key).ok()?;
|
||||
let plaintext = cipher
|
||||
.decrypt(Nonce::from_slice(&nonce), wrapper_ciphertext)
|
||||
.ok()?;
|
||||
if plaintext.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut v_me = [0u8; 32];
|
||||
v_me.copy_from_slice(&plaintext);
|
||||
Some(v_me)
|
||||
}
|
||||
|
||||
/// Encrypt a post with a provided CEK, wrapping for recipients.
|
||||
/// Returns `(base64_ciphertext, Vec<WrappedKey>)`.
|
||||
pub fn encrypt_post_with_cek(
|
||||
|
|
@ -1347,4 +1455,71 @@ mod tests {
|
|||
// Different calls produce different noise (with very high probability)
|
||||
assert_ne!(random_slot_noise(64), random_slot_noise(64));
|
||||
}
|
||||
|
||||
// --- FoF Layer 1: vouch-grant seal/open ---
|
||||
|
||||
fn make_persona_x25519(seed_byte: u8) -> ([u8; 32], [u8; 32]) {
|
||||
// Derive (x25519_priv, x25519_pub) from an ed25519 seed, mirroring
|
||||
// the production path personas use.
|
||||
let mut seed = [0u8; 32];
|
||||
seed[0] = seed_byte;
|
||||
let priv_x = ed25519_seed_to_x25519_private(&seed);
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let pub_x = signing_key.verifying_key().to_montgomery().to_bytes();
|
||||
(priv_x, pub_x)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vouch_grant_roundtrip() {
|
||||
let (alice_priv, _alice_pub) = make_persona_x25519(11);
|
||||
let (bob_priv, bob_pub) = make_persona_x25519(22);
|
||||
let bio_post_id: PostId = [7u8; 32];
|
||||
let v_me: [u8; 32] = [42u8; 32];
|
||||
|
||||
let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral();
|
||||
|
||||
// Seal for Bob
|
||||
let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &bio_post_id, &v_me).unwrap();
|
||||
assert_eq!(wrapper.len(), 48, "wrapper must be 48 bytes (32 sealed + 16 tag)");
|
||||
|
||||
// Bob opens it
|
||||
let opened = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &wrapper);
|
||||
assert_eq!(opened, Some(v_me));
|
||||
|
||||
// Alice (not the recipient) cannot open it
|
||||
let alice_attempt = open_vouch_grant(&alice_priv, &eph_pub, &bio_post_id, &wrapper);
|
||||
assert_eq!(alice_attempt, None, "non-recipient must not decrypt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vouch_grant_wrong_bio_post_id_fails() {
|
||||
let (_, bob_pub) = make_persona_x25519(22);
|
||||
let (bob_priv, _) = make_persona_x25519(22);
|
||||
let real_bio_id: PostId = [1u8; 32];
|
||||
let wrong_bio_id: PostId = [2u8; 32];
|
||||
let v_me: [u8; 32] = [99u8; 32];
|
||||
|
||||
let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral();
|
||||
let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &real_bio_id, &v_me).unwrap();
|
||||
|
||||
// Wrong bio_post_id derives a different key+nonce → AEAD fails.
|
||||
let attempt = open_vouch_grant(&bob_priv, &eph_pub, &wrong_bio_id, &wrapper);
|
||||
assert_eq!(attempt, None);
|
||||
|
||||
// Right bio_post_id succeeds.
|
||||
let ok = open_vouch_grant(&bob_priv, &eph_pub, &real_bio_id, &wrapper);
|
||||
assert_eq!(ok, Some(v_me));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vouch_grant_random_bytes_fail() {
|
||||
let (bob_priv, _) = make_persona_x25519(22);
|
||||
let bio_post_id: PostId = [5u8; 32];
|
||||
let (_, eph_pub) = generate_vouch_batch_ephemeral();
|
||||
|
||||
let mut junk = [0u8; 48];
|
||||
rand::rng().fill_bytes(&mut junk);
|
||||
let attempt = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &junk);
|
||||
assert_eq!(attempt, None, "random bytes must AEAD-fail (dummy wrapper indistinguishable)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue