feat(core/org): org types, manifest, and X25519 key wrap/unwrap (Zeroizing KDF)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJo44YM3UbBjro2fG6NrKy
This commit is contained in:
@@ -94,6 +94,11 @@ pub mod device;
|
|||||||
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||||
|
|
||||||
pub mod org;
|
pub mod org;
|
||||||
|
pub use org::{
|
||||||
|
generate_org_key, unwrap_org_key, wrap_org_key,
|
||||||
|
CollectionDef, MemberId, OrgCollections, OrgId, OrgManifest,
|
||||||
|
OrgManifestEntry, OrgMember, OrgMembers, OrgMeta, OrgRole,
|
||||||
|
};
|
||||||
|
|
||||||
pub mod tar_safe;
|
pub mod tar_safe;
|
||||||
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
||||||
|
|||||||
@@ -1 +1,493 @@
|
|||||||
//! Org vault types, crypto, and schema for multi-user self-hosted deployments.
|
//! Org vault types, crypto, and schema for multi-user self-hosted deployments.
|
||||||
|
|
||||||
|
use rand::{rngs::OsRng, RngCore};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
use crate::ids::ItemId;
|
||||||
|
use crate::item_types::ItemType;
|
||||||
|
|
||||||
|
// ── IDs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct OrgId(pub String);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct MemberId(pub String);
|
||||||
|
|
||||||
|
impl OrgId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut bytes = [0u8; 8];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
Self(hex::encode(bytes))
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str { &self.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrgId {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemberId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut bytes = [0u8; 8];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
Self(hex::encode(bytes))
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str { &self.0 }
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MemberId {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Roles ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum OrgRole {
|
||||||
|
Owner,
|
||||||
|
Admin,
|
||||||
|
Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgRole {
|
||||||
|
pub fn can_manage_members(&self) -> bool {
|
||||||
|
matches!(self, OrgRole::Owner | OrgRole::Admin)
|
||||||
|
}
|
||||||
|
pub fn can_manage_owners(&self) -> bool {
|
||||||
|
matches!(self, OrgRole::Owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Members ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgMember {
|
||||||
|
pub member_id: MemberId,
|
||||||
|
pub display_name: String,
|
||||||
|
pub role: OrgRole,
|
||||||
|
/// SSH public key string (openssh format: "ssh-ed25519 AAAA...")
|
||||||
|
pub ed25519_pubkey: String,
|
||||||
|
/// Collection slugs this member can access.
|
||||||
|
#[serde(default)]
|
||||||
|
pub collections: Vec<String>,
|
||||||
|
pub added_at: i64,
|
||||||
|
pub added_by: MemberId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgMembers {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub members: Vec<OrgMember>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgMembers {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { schema_version: 1, members: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_by_id(&self, id: &MemberId) -> Option<&OrgMember> {
|
||||||
|
self.members.iter().find(|m| &m.member_id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_by_id_mut(&mut self, id: &MemberId) -> Option<&mut OrgMember> {
|
||||||
|
self.members.iter_mut().find(|m| &m.member_id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
for m in &self.members {
|
||||||
|
if !m.member_id.is_valid() {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
format!("invalid member_id: {}", m.member_id.0)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrgMembers {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collections ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CollectionDef {
|
||||||
|
pub slug: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub created_by: MemberId,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgCollections {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub collections: Vec<CollectionDef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgCollections {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { schema_version: 1, collections: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains_slug(&self, slug: &str) -> bool {
|
||||||
|
self.collections.iter().any(|c| c.slug == slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
for c in &self.collections {
|
||||||
|
if c.slug.is_empty() || c.slug.contains('/') || c.slug.contains('.') {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
format!("invalid collection slug: {:?}", c.slug)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrgCollections {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Org meta ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgMeta {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub org_id: OrgId,
|
||||||
|
pub display_name: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgMeta {
|
||||||
|
pub fn new(display_name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: 1,
|
||||||
|
org_id: OrgId::new(),
|
||||||
|
display_name,
|
||||||
|
created_at: crate::time::now_unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Org manifest ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgManifestEntry {
|
||||||
|
pub id: ItemId,
|
||||||
|
pub r#type: ItemType,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub modified: i64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub trashed_at: Option<i64>,
|
||||||
|
/// Collection this item belongs to.
|
||||||
|
pub collection: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgManifest {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub entries: Vec<OrgManifestEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgManifest {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { schema_version: 1, entries: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return only entries whose collection is in `member.collections`.
|
||||||
|
pub fn filter_for_member(&self, member: &OrgMember) -> Self {
|
||||||
|
let granted: std::collections::HashSet<&str> =
|
||||||
|
member.collections.iter().map(|s| s.as_str()).collect();
|
||||||
|
Self {
|
||||||
|
schema_version: self.schema_version,
|
||||||
|
entries: self.entries.iter()
|
||||||
|
.filter(|e| granted.contains(e.collection.as_str()))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrgManifest {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key wrap / unwrap (ECIES: X25519 + XChaCha20-Poly1305) ───────────────────
|
||||||
|
|
||||||
|
/// Generate a random 256-bit org master key.
|
||||||
|
pub fn generate_org_key() -> Zeroizing<[u8; 32]> {
|
||||||
|
let mut key = Zeroizing::new([0u8; 32]);
|
||||||
|
OsRng.fill_bytes(key.as_mut());
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive an X25519 static secret from an ed25519 seed (standard RFC 7748 path).
|
||||||
|
fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> x25519_dalek::StaticSecret {
|
||||||
|
use sha2::{Digest, Sha512};
|
||||||
|
let h = Sha512::digest(seed.as_ref());
|
||||||
|
let mut scalar = [0u8; 32];
|
||||||
|
scalar.copy_from_slice(&h[..32]);
|
||||||
|
// RFC 7748 clamping
|
||||||
|
scalar[0] &= 248;
|
||||||
|
scalar[31] &= 127;
|
||||||
|
scalar[31] |= 64;
|
||||||
|
x25519_dalek::StaticSecret::from(scalar)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an OpenSSH ed25519 public key string and return its X25519 form.
|
||||||
|
fn openssh_ed25519_to_x25519_pk(openssh: &str) -> Result<x25519_dalek::PublicKey> {
|
||||||
|
use ssh_key::PublicKey;
|
||||||
|
let pk = PublicKey::from_openssh(openssh.trim())
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("bad SSH pubkey: {e}")))?;
|
||||||
|
let ed_bytes = pk.key_data().ed25519()
|
||||||
|
.ok_or_else(|| RelicarioError::Format("expected ed25519 key".into()))?
|
||||||
|
.0;
|
||||||
|
let verifying = ed25519_dalek::VerifyingKey::from_bytes(&ed_bytes)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("bad ed25519 pubkey: {e}")))?;
|
||||||
|
Ok(x25519_dalek::PublicKey::from(verifying.to_montgomery().to_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap `org_key` for a recipient identified by their OpenSSH ed25519 public key.
|
||||||
|
///
|
||||||
|
/// Output layout: `ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag`
|
||||||
|
pub fn wrap_org_key(org_key: &Zeroizing<[u8; 32]>, recipient_openssh_pubkey: &str) -> Result<Vec<u8>> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use x25519_dalek::EphemeralSecret;
|
||||||
|
|
||||||
|
let recipient_pk = openssh_ed25519_to_x25519_pk(recipient_openssh_pubkey)?;
|
||||||
|
|
||||||
|
let ephemeral_sk = EphemeralSecret::random_from_rng(OsRng);
|
||||||
|
let ephemeral_pk = x25519_dalek::PublicKey::from(&ephemeral_sk);
|
||||||
|
|
||||||
|
let shared = ephemeral_sk.diffie_hellman(&recipient_pk);
|
||||||
|
|
||||||
|
// Domain-separated KDF. All intermediates carrying the DH secret are held in
|
||||||
|
// Zeroizing so they are wiped on drop (H6).
|
||||||
|
let mut kdf_input: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32));
|
||||||
|
kdf_input.extend_from_slice(shared.as_bytes());
|
||||||
|
kdf_input.extend_from_slice(ephemeral_pk.as_bytes());
|
||||||
|
kdf_input.extend_from_slice(recipient_pk.as_bytes());
|
||||||
|
|
||||||
|
// Copy the digest straight into a Zeroizing array. The GenericArray returned
|
||||||
|
// by Sha256::digest is not Zeroize (generic-array's impl is feature-gated and
|
||||||
|
// not enabled here), so we move the bytes into an owned [u8; 32] whose own
|
||||||
|
// Zeroize impl wipes them on drop.
|
||||||
|
let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
|
||||||
|
wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice()));
|
||||||
|
|
||||||
|
let encrypted = crate::crypto::encrypt(&wrap_key, org_key.as_ref())?;
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(32 + encrypted.len());
|
||||||
|
out.extend_from_slice(ephemeral_pk.as_bytes());
|
||||||
|
out.extend_from_slice(&encrypted);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwrap a key blob produced by `wrap_org_key` using the recipient's ed25519 seed.
|
||||||
|
pub fn unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8; 32]>) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
// Minimum: 32 (ephemeral_pk) + 41 (version+nonce+tag for 32-byte plaintext)
|
||||||
|
if wrapped.len() < 32 + 41 {
|
||||||
|
return Err(RelicarioError::Format("wrapped key blob too short".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ephemeral_pk = x25519_dalek::PublicKey::from(
|
||||||
|
<[u8; 32]>::try_from(&wrapped[..32]).unwrap()
|
||||||
|
);
|
||||||
|
let encrypted = &wrapped[32..];
|
||||||
|
|
||||||
|
let recipient_sk = ed25519_seed_to_x25519_secret(ed25519_seed);
|
||||||
|
let recipient_pk = x25519_dalek::PublicKey::from(&recipient_sk);
|
||||||
|
|
||||||
|
let shared = recipient_sk.diffie_hellman(&ephemeral_pk);
|
||||||
|
|
||||||
|
let mut kdf_input: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32));
|
||||||
|
kdf_input.extend_from_slice(shared.as_bytes());
|
||||||
|
kdf_input.extend_from_slice(ephemeral_pk.as_bytes());
|
||||||
|
kdf_input.extend_from_slice(recipient_pk.as_bytes());
|
||||||
|
|
||||||
|
let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
|
||||||
|
wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice()));
|
||||||
|
|
||||||
|
let plaintext = Zeroizing::new(crate::crypto::decrypt(&wrap_key, encrypted)?);
|
||||||
|
if plaintext.len() != 32 {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
format!("unwrapped key has wrong length: {}", plaintext.len())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut key = Zeroizing::new([0u8; 32]);
|
||||||
|
key.copy_from_slice(&plaintext);
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_id_is_16_hex_chars() {
|
||||||
|
let id = MemberId::new();
|
||||||
|
assert_eq!(id.0.len(), 16);
|
||||||
|
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_ids_are_unique() {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for _ in 0..1_000 {
|
||||||
|
assert!(seen.insert(MemberId::new().0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_id_is_16_hex_chars() {
|
||||||
|
let id = OrgId::new();
|
||||||
|
assert_eq!(id.0.len(), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_role_can_manage_members() {
|
||||||
|
assert!(OrgRole::Owner.can_manage_members());
|
||||||
|
assert!(OrgRole::Admin.can_manage_members());
|
||||||
|
assert!(!OrgRole::Member.can_manage_members());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collection_slug_validation_rejects_slash() {
|
||||||
|
let mut c = OrgCollections::new();
|
||||||
|
c.collections.push(CollectionDef {
|
||||||
|
slug: "bad/slug".into(),
|
||||||
|
display_name: "Bad".into(),
|
||||||
|
created_by: MemberId::new(),
|
||||||
|
created_at: 0,
|
||||||
|
});
|
||||||
|
assert!(c.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_for_member_restricts_collections() {
|
||||||
|
let mut manifest = OrgManifest::new();
|
||||||
|
manifest.entries.push(OrgManifestEntry {
|
||||||
|
id: ItemId::new(),
|
||||||
|
r#type: crate::item_types::ItemType::SecureNote,
|
||||||
|
title: "A".into(),
|
||||||
|
tags: vec![],
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: "prod".into(),
|
||||||
|
});
|
||||||
|
manifest.entries.push(OrgManifestEntry {
|
||||||
|
id: ItemId::new(),
|
||||||
|
r#type: crate::item_types::ItemType::SecureNote,
|
||||||
|
title: "B".into(),
|
||||||
|
tags: vec![],
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: "dev".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let member = OrgMember {
|
||||||
|
member_id: MemberId::new(),
|
||||||
|
display_name: "Alice".into(),
|
||||||
|
role: OrgRole::Member,
|
||||||
|
ed25519_pubkey: String::new(),
|
||||||
|
collections: vec!["prod".into()],
|
||||||
|
added_at: 0,
|
||||||
|
added_by: MemberId::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filtered = manifest.filter_for_member(&member);
|
||||||
|
assert_eq!(filtered.entries.len(), 1);
|
||||||
|
assert_eq!(filtered.entries[0].collection, "prod");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_org_key_is_32_bytes() {
|
||||||
|
let key = generate_org_key();
|
||||||
|
assert_eq!(key.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pinned RFC 8032 known-answer vector for the ed25519→X25519 map. The seed
|
||||||
|
/// and expected X25519 public key are from ed25519-dalek's own reference
|
||||||
|
/// test (`tests/x25519.rs`, section 7.1 vector A). The expected value is a
|
||||||
|
/// HARD-CODED LITERAL — NOT recomputed by the production code path — so a
|
||||||
|
/// correlated cross-crate-version regression in the birational map (where
|
||||||
|
/// both our derivation and a naive re-derivation would drift together) is
|
||||||
|
/// still caught. If this test ever fails after a dep bump, the wrap/unwrap
|
||||||
|
/// keyspace changed and every existing `keys/<id>.enc` blob is invalidated.
|
||||||
|
#[test]
|
||||||
|
fn ed25519_to_x25519_pinned_rfc8032_vector() {
|
||||||
|
let seed: [u8; 32] =
|
||||||
|
hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
// Derive the X25519 *public* key the same way wrap/unwrap derives the
|
||||||
|
// recipient's static secret from a seed.
|
||||||
|
let secret = ed25519_seed_to_x25519_secret(&seed);
|
||||||
|
let public = x25519_dalek::PublicKey::from(&secret);
|
||||||
|
assert_eq!(
|
||||||
|
hex::encode(public.as_bytes()),
|
||||||
|
"d85e07ec22b0ad881537c2f44d662d1a143cf830c57aca4305d85c7a90f6b62e",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrap_unwrap_round_trip() {
|
||||||
|
// Generate an ed25519 keypair to act as the member's device key
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
let mut seed = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut seed);
|
||||||
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
|
let pubkey_openssh = ssh_key::PrivateKey::from(
|
||||||
|
ssh_key::private::Ed25519Keypair::from(&signing_key),
|
||||||
|
)
|
||||||
|
.public_key()
|
||||||
|
.to_openssh()
|
||||||
|
.expect("openssh");
|
||||||
|
|
||||||
|
let org_key = generate_org_key();
|
||||||
|
let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap");
|
||||||
|
let seed_zeroizing = Zeroizing::new(seed);
|
||||||
|
let unwrapped = unwrap_org_key(&wrapped, &seed_zeroizing).expect("unwrap");
|
||||||
|
|
||||||
|
assert_eq!(*org_key, *unwrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unwrap_with_wrong_seed_fails() {
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
let mut seed = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut seed);
|
||||||
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
|
let pubkey_openssh = ssh_key::PrivateKey::from(
|
||||||
|
ssh_key::private::Ed25519Keypair::from(&signing_key),
|
||||||
|
)
|
||||||
|
.public_key()
|
||||||
|
.to_openssh()
|
||||||
|
.expect("openssh");
|
||||||
|
|
||||||
|
let org_key = generate_org_key();
|
||||||
|
let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap");
|
||||||
|
|
||||||
|
let wrong_seed = Zeroizing::new([0xFFu8; 32]);
|
||||||
|
let result = unwrap_org_key(&wrapped, &wrong_seed);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user