Skip to content
Clone

feat(lib): add new lib/secret crate and missing handler modules (repo activity, space channels create/update)

zhenyi committed
parent b26d06b commit 649265f Browse files

10 files changed

+246 −0

File tree 10
Added lib/repo/handler/activity/mod.rs View lib/repo/handler/activity/mod.rs
@@ -0,0 +1 @@
1pub(crate) mod list;
No newline at end of file
Added lib/secret/Cargo.toml View lib/secret/Cargo.toml
@@ -0,0 +1,24 @@
1[package]
2name = "secret"
3version.workspace = true
4edition.workspace = true
5authors.workspace = true
6categories.workspace = true
7keywords.workspace = true
8repository.workspace = true
9homepage.workspace = true
10
11[lib]
12path = "lib.rs"
13name = "secret"
14
15[dependencies]
16aes-gcm = { workspace = true }
17base64 = { workspace = true }
18hex = { workspace = true }
19rand = { workspace = true }
20serde = { workspace = true }
21serde_json = { workspace = true }
22sha2 = { workspace = true }
23thiserror = { workspace = true }
24tracing = { workspace = true }
Added lib/secret/credential_ref.rs View lib/secret/credential_ref.rs
@@ -0,0 +1,18 @@
1use crate::error::SecretError;
2
3pub const ENV_PREFIX: &str = "env:";
4
5/// Resolve a credential reference. Supported forms:
6/// - `env:VARNAME` → read from environment (missing or empty → error)
7/// - anything else → treated as literal value, returned unchanged
8pub fn resolve_credential_ref(reference: &str) -> Result<String, SecretError> {
9 match reference.strip_prefix(ENV_PREFIX) {
10 Some(var) if !var.is_empty() => match std::env::var(var) {
11 Ok(v) if !v.is_empty() => Ok(v),
12 _ => Err(SecretError::MissingEnvVar {
13 name: var.to_string(),
14 }),
15 },
16 _ => Ok(reference.to_string()),
17 }
18}
Added lib/secret/envelope.rs View lib/secret/envelope.rs
@@ -0,0 +1,76 @@
1use base64::Engine;
2
3use crate::error::SecretError;
4
5pub const ENVELOPE_PREFIX: &str = "envelope:v1:";
6
7pub fn is_envelope(value: &str) -> bool {
8 value.starts_with(ENVELOPE_PREFIX)
9}
10
11pub fn decode_key(hex_key: &str) -> Result<Vec<u8>, SecretError> {
12 let key =
13 hex::decode(hex_key).map_err(|e| SecretError::InvalidEnvelopeKey(e.to_string()))?;
14 if key.len() != 32 {
15 return Err(SecretError::InvalidEnvelopeKey(hex_key.to_string()));
16 }
17 Ok(key)
18}
19
20pub fn envelope_key_from_env() -> Result<Vec<u8>, SecretError> {
21 let raw = std::env::var("LLM_ENVELOPE_KEY").map_err(|_| SecretError::MissingEnvelopeKey)?;
22 decode_key(&raw)
23}
24
25fn cipher(key: &[u8]) -> Result<aes_gcm::Aes256Gcm, SecretError> {
26 use aes_gcm::KeyInit;
27 aes_gcm::Aes256Gcm::new_from_slice(key).map_err(|e| SecretError::Crypto(e.to_string()))
28}
29
30pub fn seal(key: &[u8], plaintext: &[u8]) -> Result<String, SecretError> {
31 use aes_gcm::aead::Aead;
32 let nonce_bytes: [u8; 12] = rand::random();
33 #[allow(deprecated)]
34 let nonce = aes_gcm::Nonce::from_slice(&nonce_bytes);
35 let ct = cipher(key)?
36 .encrypt(nonce, plaintext)
37 .map_err(|e| SecretError::Crypto(e.to_string()))?;
38 let b64 = base64::engine::general_purpose::STANDARD;
39 Ok(format!(
40 "{}{}:{}",
41 ENVELOPE_PREFIX,
42 b64.encode(nonce_bytes),
43 b64.encode(ct)
44 ))
45}
46
47pub fn open(key: &[u8], value: &str) -> Result<String, SecretError> {
48 use aes_gcm::aead::Aead;
49 let rest = value
50 .strip_prefix(ENVELOPE_PREFIX)
51 .ok_or_else(|| SecretError::InvalidEnvelope(value.to_string()))?;
52 let (nonce_b64, ct_b64) = rest
53 .split_once(':')
54 .ok_or_else(|| SecretError::InvalidEnvelope(value.to_string()))?;
55 let b64 = base64::engine::general_purpose::STANDARD;
56 let nonce_bytes = b64
57 .decode(nonce_b64)
58 .map_err(|e| SecretError::InvalidEnvelope(e.to_string()))?;
59 let ct = b64
60 .decode(ct_b64)
61 .map_err(|e| SecretError::InvalidEnvelope(e.to_string()))?;
62 #[allow(deprecated)]
63 let nonce = aes_gcm::Nonce::from_slice(&nonce_bytes);
64 let pt = cipher(key)?
65 .decrypt(nonce, ct.as_slice())
66 .map_err(|e| SecretError::Crypto(e.to_string()))?;
67 Ok(String::from_utf8(pt)?)
68}
69
70pub fn resolve(key: &[u8], value: &str) -> Result<String, SecretError> {
71 if is_envelope(value) {
72 open(key, value)
73 } else {
74 Ok(value.to_string())
75 }
76}
Added lib/secret/error.rs View lib/secret/error.rs
@@ -0,0 +1,21 @@
1use thiserror::Error;
2
3#[derive(Debug, Error)]
4pub enum SecretError {
5 #[error("environment variable {name} not set or empty")]
6 MissingEnvVar { name: String },
7 #[error("invalid credential_ref: {0}")]
8 InvalidCredentialRef(String),
9 #[error("invalid envelope format: {0}")]
10 InvalidEnvelope(String),
11 #[error("envelope key not configured (set LLM_ENVELOPE_KEY)")]
12 MissingEnvelopeKey,
13 #[error("envelope key must be 32 bytes hex, got: {0}")]
14 InvalidEnvelopeKey(String),
15 #[error("envelope crypto failed: {0}")]
16 Crypto(String),
17 #[error("utf8: {0}")]
18 Utf8(#[from] std::string::FromUtf8Error),
19 #[error("io: {0}")]
20 Io(#[from] std::io::Error),
21}
Added lib/secret/fingerprint.rs View lib/secret/fingerprint.rs
@@ -0,0 +1,8 @@
1use sha2::{Digest, Sha256};
2
3/// Stable, truncated SHA-256 fingerprint of a secret value.
4/// Used in logs and gRPC responses instead of the plaintext credential.
5pub fn key_fingerprint(secret: &str) -> String {
6 let digest = Sha256::digest(secret.as_bytes());
7 hex::encode(digest)[..16].to_string()
8}
Added lib/secret/lib.rs View lib/secret/lib.rs
@@ -0,0 +1,11 @@
1mod credential_ref;
2mod envelope;
3mod error;
4mod fingerprint;
5mod mtls;
6
7pub use credential_ref::{resolve_credential_ref, ENV_PREFIX};
8pub use envelope::{envelope_key_from_env, is_envelope, open, resolve, seal, ENVELOPE_PREFIX};
9pub use error::SecretError;
10pub use fingerprint::key_fingerprint;
11pub use mtls::{load_identity, MtlsIdentity};
Added lib/secret/mtls.rs View lib/secret/mtls.rs
@@ -0,0 +1,17 @@
1use std::path::Path;
2
3use crate::error::SecretError;
4
5/// PEM certificate + key pair loaded from disk, for building a `tonic`
6/// server identity (Admin gRPC mTLS).
7pub struct MtlsIdentity {
8 pub cert_pem: Vec<u8>,
9 pub key_pem: Vec<u8>,
10}
11
12pub fn load_identity(cert_path: &Path, key_path: &Path) -> Result<MtlsIdentity, SecretError> {
13 Ok(MtlsIdentity {
14 cert_pem: std::fs::read(cert_path)?,
15 key_pem: std::fs::read(key_path)?,
16 })
17}
Added lib/space/handler/channels/create.rs View lib/space/handler/channels/create.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Path, State};
2use jwt::extractor::AuthUser;
3
4use crate::error::SpaceResult;
5use crate::features::SpaceService;
6use crate::features::channels::{ChannelView, CreateDirectoryCmd};
7use export::data::{AppRequest, AppRequestData, AppResponseData};
8
9#[utoipa::path(
10 post,
11 path = "/spaces/{space_name}/channels",
12 tag = "space",
13 params(("space_name" = String, Path)),
14 request_body = AppRequestData<CreateDirectoryCmd>,
15 responses((status = 200, description = "Create a directory channel", body = AppResponseData<ChannelView>)),
16 security(("bearer_auth" = []))
17)]
18pub(crate) async fn handler(
19 State(svc): State<SpaceService>,
20 AuthUser { claims }: AuthUser,
21 Path(space_name): Path<String>,
22 cmd: AppRequest<CreateDirectoryCmd>,
23) -> SpaceResult<AppResponseData<ChannelView>> {
24 let space_id = crate::features::lookup::lookup_space_by_name(&svc, &space_name).await?;
25 let cmd = cmd.into_inner();
26 let view = svc
27 .create_directory(
28 claims.sub,
29 space_id,
30 cmd.parent_id,
31 &cmd.name,
32 cmd.icon,
33 cmd.color,
34 )
35 .await?;
36 Ok(AppResponseData::ok(view))
37}
No newline at end of file
Added lib/space/handler/channels/update.rs View lib/space/handler/channels/update.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Path, State};
2use jwt::extractor::AuthUser;
3use uuid::Uuid;
4
5use crate::error::SpaceResult;
6use crate::features::SpaceService;
7use crate::features::channels::{ChannelView, UpdateChannelCmd};
8use export::data::{AppRequest, AppRequestData, AppResponseData};
9
10#[utoipa::path(
11 patch,
12 path = "/spaces/{space_name}/channels/{cid}",
13 params(
14 ("space_name" = String, Path),
15 ("cid" = Uuid, Path),
16 ),
17 tag = "space",
18 request_body = AppRequestData<UpdateChannelCmd>,
19 responses((status = 200, description = "Update a channel", body = AppResponseData<ChannelView>)),
20 security(("bearer_auth" = []))
21)]
22pub(crate) async fn handler(
23 State(svc): State<SpaceService>,
24 AuthUser { claims }: AuthUser,
25 Path((space_name, cid)): Path<(String, Uuid)>,
26 cmd: AppRequest<UpdateChannelCmd>,
27) -> SpaceResult<AppResponseData<ChannelView>> {
28 let space_id = crate::features::lookup::lookup_space_by_name(&svc, &space_name).await?;
29 let view = svc
30 .update_channel(claims.sub, space_id, cid, cmd.into_inner())
31 .await?;
32 Ok(AppResponseData::ok(view))
33}
No newline at end of file