jacs_core/agent.rs
1//! `CoreAgent`: protocol-layer agent state — no I/O, no schema validation.
2//!
3//! `CoreAgent` carries the four things needed to sign or verify on the
4//! protocol layer:
5//!
6//! 1. The signing algorithm (`SigningAlgorithm`).
7//! 2. The public-key bytes (always present, even after `clear_secrets`).
8//! 3. An optional `DetachedSigner` (present when unlocked, dropped when
9//! locked).
10//! 4. The agent JSON document (for `agent_id` / `agent_version` extraction
11//! when constructing signature payloads in Task 013).
12//!
13//! It is intentionally minimal — no DNS, no registry, no schema validation,
14//! no `MultiStorage`. Those live in `jacs` / `jacs-wasm` on top of this.
15//!
16//! See PRD §4.2, §4.4.
17
18use crate::CoreError;
19use crate::envelope::decrypt_private_key;
20use crate::material::{AgentMaterial, UnlockSecret};
21use crate::sign::{DetachedSigner, Ed25519DalekSigner, Pq2025Signer, SigningAlgorithm};
22use crate::verify::{
23 VerificationOutcome, build_signature_content_v2, build_signature_metadata,
24 default_signed_fields, sha256_hex, verify_document,
25};
26use base64::Engine as _;
27use secrecy::ExposeSecret;
28use serde_json::{Value, json};
29
30/// Placement key for the JACS document signature. Mirrors
31/// `jacs::storage::JACS_SIGNATURE_FIELDNAME`. Hardcoded here because
32/// jacs-core does not depend on jacs.
33const JACS_SIGNATURE_FIELDNAME: &str = "jacsSignature";
34
35// =========================================================================
36// CoreAgent
37// =========================================================================
38
39/// In-memory agent holding the optional unlocked signer + the published
40/// public key + the embedded agent JSON.
41///
42/// `CoreAgent` is constructed by either:
43///
44/// - [`CoreAgent::from_encrypted_material`] — production path, takes an
45/// `AgentMaterial` and an `UnlockSecret`.
46/// - [`CoreAgent::ephemeral`] — testing / one-off path, generates a fresh
47/// keypair and synthesizes a minimal agent JSON.
48///
49/// Signing and verification methods are added in Task 013 and live in the
50/// `verify` module + an extended `impl` block.
51pub struct CoreAgent {
52 /// The signer is dropped when `clear_secrets` runs; the trait's own
53 /// implementations zeroize their private-key bytes on drop.
54 pub(crate) signer: Option<Box<dyn DetachedSigner>>,
55 pub(crate) algorithm: SigningAlgorithm,
56 pub(crate) public_key: Vec<u8>,
57 pub(crate) agent_json: Value,
58}
59
60impl std::fmt::Debug for CoreAgent {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("CoreAgent")
63 .field("algorithm", &self.algorithm)
64 .field("public_key_len", &self.public_key.len())
65 .field("unlocked", &self.signer.is_some())
66 .finish()
67 }
68}
69
70impl CoreAgent {
71 /// Construct from encrypted material plus an unlock secret.
72 ///
73 /// `Password` runs the envelope through the V2/legacy sniffer in
74 /// `envelope::decrypt_private_key`. `RawPrivateKey` takes the bytes
75 /// as-is.
76 ///
77 /// Errors mirror the underlying primitives: `InvalidPassword`,
78 /// `MalformedEnvelope`, `MalformedKey`, `UnsupportedAlgorithm`.
79 pub fn from_encrypted_material(
80 material: AgentMaterial,
81 secret: UnlockSecret<'_>,
82 ) -> Result<Self, CoreError> {
83 let signer: Box<dyn DetachedSigner> = match secret {
84 UnlockSecret::Password(password) => {
85 let decrypted = decrypt_private_key(&material.encrypted_private_key, password)?;
86 build_signer(material.algorithm, decrypted.as_slice())?
87 }
88 UnlockSecret::RawPrivateKey(secret_box) => {
89 build_signer(material.algorithm, secret_box.expose_secret())?
90 }
91 };
92
93 // Sanity check: the public key the caller stored must match the
94 // public key derived from the unlocked private key. Otherwise the
95 // agent could sign with one key while presenting another, which
96 // would yield silent verification failures downstream.
97 if signer.public_key() != material.public_key.as_slice() {
98 return Err(CoreError::MalformedKey(
99 "stored public key does not match the key derived from the unlocked private key"
100 .into(),
101 ));
102 }
103
104 Ok(Self {
105 signer: Some(signer),
106 algorithm: material.algorithm,
107 public_key: material.public_key,
108 agent_json: material.agent,
109 })
110 }
111
112 /// Generate a fresh ephemeral agent for the given algorithm. Synthesizes
113 /// a minimal agent JSON via [`ephemeral_agent_json`] so the result
114 /// looks like an agent for downstream sign / verify code paths (Task
115 /// 013) without taking a dependency on the full native agent loader.
116 pub fn ephemeral(algorithm: SigningAlgorithm) -> Result<Self, CoreError> {
117 let signer: Box<dyn DetachedSigner> = match algorithm {
118 SigningAlgorithm::Ed25519 => Box::new(Ed25519DalekSigner::generate()?),
119 SigningAlgorithm::Pq2025 => Box::new(Pq2025Signer::generate()?),
120 };
121 let public_key = signer.public_key().to_vec();
122 let agent_json = ephemeral_agent_json(algorithm, &public_key);
123 Ok(Self {
124 signer: Some(signer),
125 algorithm,
126 public_key,
127 agent_json,
128 })
129 }
130
131 /// The signing algorithm of this agent.
132 pub fn algorithm(&self) -> SigningAlgorithm {
133 self.algorithm
134 }
135
136 /// Raw public-key bytes. Survives `clear_secrets` — verification with
137 /// this agent still works after the private key is dropped.
138 pub fn public_key(&self) -> &[u8] {
139 &self.public_key
140 }
141
142 /// `true` iff a signer is currently held (a private key is unlocked).
143 pub fn is_unlocked(&self) -> bool {
144 self.signer.is_some()
145 }
146
147 /// Idempotent secret eviction. After this call:
148 ///
149 /// - `is_unlocked()` returns `false`.
150 /// - `sign_message` (Task 013) returns `CoreError::Locked`.
151 /// - `public_key`, `algorithm`, `verify`, `verify_with_key` continue to
152 /// work.
153 pub fn clear_secrets(&mut self) {
154 if let Some(signer) = self.signer.as_mut() {
155 // Belt-and-braces: ask the trait impl to wipe its inner secret
156 // before we drop the box. Both `Ed25519DalekSigner` and
157 // `Pq2025Signer` already zeroize on drop, but exercising the
158 // hook keeps the contract aligned with what the trait
159 // promises (idempotent, no panic, no observable change after
160 // the second call).
161 signer.clear_secrets();
162 }
163 self.signer = None;
164 }
165
166 /// Borrow a clone of the embedded agent JSON. Used by callers (browser
167 /// or native facade) that want to re-emit the agent record without
168 /// taking ownership of the `CoreAgent`.
169 pub fn export_agent(&self) -> Value {
170 self.agent_json.clone()
171 }
172
173 /// Round-trip the unlocked agent into an `AgentMaterial` whose
174 /// `encrypted_private_key` is encrypted under `password` with the
175 /// V2 Argon2id envelope (`envelope::encrypt_private_key`).
176 ///
177 /// The result is the same shape `from_encrypted_material` accepts —
178 /// the wasm browser layer round-trips through this method to
179 /// implement `BrowserAgent.save(storageKey)` / `load(storageKey,
180 /// {password})` (HAIAI_WASM Issue 003) without any local crypto in
181 /// the wrapper.
182 ///
183 /// Returns `CoreError::Locked` if the signer has been cleared, or
184 /// the underlying `EncryptionFailed` if envelope encryption fails.
185 pub fn export_encrypted_material(&self, password: &str) -> Result<AgentMaterial, CoreError> {
186 let signer = self.signer.as_ref().ok_or(CoreError::Locked)?;
187 let raw_private = signer.export_private_key_bytes()?;
188 let encrypted = crate::envelope::encrypt_private_key(&raw_private, password)?;
189 // Zeroize the intermediate plaintext as soon as we have the
190 // ciphertext — defense-in-depth even though `raw_private` will
191 // drop at scope exit anyway. Using `zeroize::Zeroize` keeps the
192 // wipe explicit + compiler-resistant.
193 use zeroize::Zeroize as _;
194 let mut raw_private = raw_private;
195 raw_private.zeroize();
196 Ok(AgentMaterial {
197 // Browser ephemeral agents don't carry a full `jacs.config.json`
198 // — emit an empty object as a placeholder. Round-trip readers
199 // (CoreAgent::from_encrypted_material) ignore `config`; the
200 // shape is preserved purely for storage-layer consumers
201 // (jacs-wasm::local_store::validate_encrypted_material_shape).
202 config: serde_json::json!({}),
203 agent: self.agent_json.clone(),
204 public_key: self.public_key.clone(),
205 encrypted_private_key: encrypted,
206 algorithm: self.algorithm,
207 })
208 }
209
210 // =====================================================================
211 // sign / verify
212 // =====================================================================
213
214 /// Sign a JSON payload as a JACS message and return the signed
215 /// document. Shape:
216 ///
217 /// ```json
218 /// {
219 /// "jacsType": "message",
220 /// "jacsLevel": "raw",
221 /// "content": { ... },
222 /// "jacsSignature": { ... }
223 /// }
224 /// ```
225 ///
226 /// The canonical signature payload is built per PRD §4.5 (v2 layout,
227 /// `serde_json_canonicalizer` for canonical JSON). The signer must be
228 /// unlocked; otherwise returns `CoreError::Locked`.
229 pub fn sign_message(&mut self, data: &Value) -> Result<Value, CoreError> {
230 // Build the wrapper document. The wasm layer signs documents in
231 // this exact shape so verifiers reconstruct the same canonical
232 // bytes regardless of platform.
233 let mut document = json!({
234 "jacsType": "message",
235 "jacsLevel": "raw",
236 "content": data,
237 });
238 self.sign_document_inplace(&mut document, JACS_SIGNATURE_FIELDNAME)?;
239 Ok(document)
240 }
241
242 /// Sign `document` in place, attaching the signature object under
243 /// `placement_key`. Used by `sign_message` (placement key `"jacsSignature"`)
244 /// and by `jacs-core::agreements` in Task 014.
245 ///
246 /// Returns `CoreError::Locked` if the signer has been cleared.
247 pub fn sign_document_inplace(
248 &mut self,
249 document: &mut Value,
250 placement_key: &str,
251 ) -> Result<(), CoreError> {
252 let signer = self.signer.as_ref().ok_or(CoreError::Locked)?;
253 let algorithm = self.algorithm;
254 let public_key_hash = sha256_hex(&self.public_key);
255 let agent_id = self
256 .agent_json
257 .get("jacsId")
258 .and_then(|v| v.as_str())
259 .unwrap_or("")
260 .to_string();
261 let agent_version = self
262 .agent_json
263 .get("jacsVersion")
264 .and_then(|v| v.as_str())
265 .unwrap_or("")
266 .to_string();
267 let date = chrono::Utc::now().to_rfc3339();
268 let iat = chrono::Utc::now().timestamp();
269 let jti = uuid::Uuid::now_v7().to_string();
270 let fields = default_signed_fields(document, placement_key);
271
272 // The metadata used for the canonical payload — `signature` field
273 // is empty here; `build_signature_content_v2` strips it anyway, but
274 // making it explicit keeps the shape consistent with what the
275 // verifier reconstructs.
276 let metadata = build_signature_metadata(
277 &agent_id,
278 &agent_version,
279 &date,
280 iat,
281 &jti,
282 algorithm,
283 &public_key_hash,
284 &fields,
285 );
286
287 let canonical = build_signature_content_v2(document, &fields, placement_key, &metadata)?;
288 let sig_bytes = signer.sign(canonical.as_bytes())?;
289 let signature_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_bytes);
290
291 // Build the final signature object: same shape as `metadata`, with
292 // the real signature filled in.
293 let mut sig_object = metadata;
294 sig_object["signature"] = json!(signature_b64);
295
296 document
297 .as_object_mut()
298 .ok_or_else(|| {
299 CoreError::MalformedDocument(
300 "document must be a JSON object to attach a signature".into(),
301 )
302 })?
303 .insert(placement_key.to_string(), sig_object);
304
305 Ok(())
306 }
307
308 /// Sign exact `bytes` with the unlocked signer and return the raw
309 /// signature bytes. No JSON wrapping, no canonicalization, no
310 /// metadata — the caller decides what bytes are signed.
311 ///
312 /// Use this for protocol primitives where the verifier reconstructs
313 /// the exact same byte string from independent inputs (auth headers,
314 /// nonce-bound challenges, JWT-style payloads). For JACS document
315 /// signing, use `sign_message` / `sign_document_inplace` instead so
316 /// the verifier can reproduce the canonical payload from the
317 /// document's published fields.
318 ///
319 /// Returns `CoreError::Locked` if `clear_secrets` has been called.
320 pub fn sign_raw_bytes(&self, bytes: &[u8]) -> Result<Vec<u8>, CoreError> {
321 let signer = self.signer.as_ref().ok_or(CoreError::Locked)?;
322 signer.sign(bytes)
323 }
324
325 /// Static verify path for `sign_raw_bytes` output. Returns `Ok(true)`
326 /// when the signature matches, `Ok(false)` when it does not, and
327 /// `Err(CoreError::UnsupportedAlgorithm)` / `MalformedKey` /
328 /// `MalformedDocument` if the inputs are structurally invalid.
329 ///
330 /// Mirrors `verify_with_key` for document signing — the verifier
331 /// does not need an unlocked agent because it only requires the
332 /// public key bytes + algorithm.
333 pub fn verify_raw_bytes_with_key(
334 public_key: &[u8],
335 algorithm: SigningAlgorithm,
336 bytes: &[u8],
337 signature: &[u8],
338 ) -> Result<bool, CoreError> {
339 match algorithm {
340 SigningAlgorithm::Ed25519 => {
341 match Ed25519DalekSigner::verify(public_key, bytes, signature) {
342 Ok(()) => Ok(true),
343 // The underlying verify surface returns `CoreError::SignatureInvalid`
344 // for a cryptographic mismatch and a structural error for
345 // bad inputs (wrong key length, bad signature length).
346 // Surface the structural errors as Err; map signature
347 // mismatch to Ok(false) so callers can branch on a
348 // valid bool without try-catching for the happy-path.
349 Err(CoreError::SignatureInvalid(_)) => Ok(false),
350 Err(other) => Err(other),
351 }
352 }
353 SigningAlgorithm::Pq2025 => match Pq2025Signer::verify(public_key, bytes, signature) {
354 Ok(()) => Ok(true),
355 Err(CoreError::SignatureInvalid(_)) => Ok(false),
356 Err(other) => Err(other),
357 },
358 }
359 }
360
361 /// Verify a signed JACS document against this agent's public key +
362 /// algorithm. Always uses the `jacsSignature` placement key.
363 ///
364 /// Returns `CoreError::AlgorithmMismatch` if the document was signed
365 /// under a different algorithm than this agent. Returns a
366 /// `VerificationOutcome` with `valid = false` and one entry in
367 /// `errors` when the signature itself does not verify.
368 pub fn verify(&self, signed: &Value) -> Result<VerificationOutcome, CoreError> {
369 verify_document(
370 signed,
371 &self.public_key,
372 self.algorithm,
373 JACS_SIGNATURE_FIELDNAME,
374 )
375 }
376
377 /// Static verify path — does not require an unlocked agent.
378 ///
379 /// `public_key` and `algorithm` must match what the document was signed
380 /// under; otherwise the cryptographic check fails and the returned
381 /// outcome has `valid = false`. The signed document's
382 /// `signingAlgorithm` field is checked against `algorithm` and returns
383 /// `CoreError::AlgorithmMismatch` on conflict — this is a typed
384 /// failure (algorithm choice errors are different from bad
385 /// signatures).
386 pub fn verify_with_key(
387 signed: &Value,
388 public_key: &[u8],
389 algorithm: SigningAlgorithm,
390 ) -> Result<VerificationOutcome, CoreError> {
391 verify_document(signed, public_key, algorithm, JACS_SIGNATURE_FIELDNAME)
392 }
393}
394
395// =========================================================================
396// Internal helpers
397// =========================================================================
398
399/// Build the concrete signer for the given algorithm + decrypted private
400/// key bytes.
401///
402/// `Ed25519` accepts either PKCS#8 v1/v2 DER (the shape that
403/// `ring::Ed25519KeyPair::generate_pkcs8` emits, and that
404/// `Ed25519DalekSigner::export_pkcs8_v2` round-trips) or the raw 32-byte
405/// scalar. `Pq2025` accepts the 4896-byte ML-DSA-87 private key.
406fn build_signer(
407 algorithm: SigningAlgorithm,
408 private_key_bytes: &[u8],
409) -> Result<Box<dyn DetachedSigner>, CoreError> {
410 match algorithm {
411 SigningAlgorithm::Ed25519 => {
412 // Prefer PKCS#8 — that's what the native `ring` path emits and
413 // what the V2 envelope stores after `Ed25519DalekSigner::
414 // export_pkcs8_v2`. Fall back to the raw-scalar shape (32
415 // bytes) so callers who deliberately store the bare key
416 // through `UnlockSecret::RawPrivateKey` still work.
417 if private_key_bytes.len() == 32 {
418 Ok(Box::new(Ed25519DalekSigner::from_private_scalar(
419 private_key_bytes,
420 )?))
421 } else {
422 Ok(Box::new(Ed25519DalekSigner::from_pkcs8(private_key_bytes)?))
423 }
424 }
425 SigningAlgorithm::Pq2025 => Ok(Box::new(Pq2025Signer::from_private_bytes(
426 private_key_bytes,
427 )?)),
428 }
429}
430
431/// Synthesize the minimal agent JSON used by `CoreAgent::ephemeral`. The
432/// shape mirrors the fields native `SimpleAgent::ephemeral` exposes after
433/// it runs the full agent-builder pipeline:
434///
435/// - `jacsId` — a fresh UUID v4 so the agent has a stable identifier even
436/// without a persisted record.
437/// - `jacsVersion` — a literal `"v1"` placeholder. The native facade will
438/// overwrite this when the same material is persisted; on the wasm side
439/// it suffices as a non-empty version string.
440/// - `name` — `"ephemeral"`.
441/// - `algorithm` — the wire form (`"ed25519"` / `"pq2025"`).
442/// - `publicKeyLen` — for diagnostics only; the raw bytes themselves stay
443/// on the `CoreAgent` and are not embedded.
444///
445/// DRY note: there is exactly one helper for ephemeral agent JSON shape
446/// (this function). `CoreAgent::ephemeral` is the only caller; callers
447/// that want to override the shape should construct an `AgentMaterial`
448/// and route through `from_encrypted_material` instead.
449pub fn ephemeral_agent_json(algorithm: SigningAlgorithm, public_key: &[u8]) -> Value {
450 json!({
451 "jacsId": uuid::Uuid::new_v4().to_string(),
452 "jacsVersion": "v1",
453 "name": "ephemeral",
454 "algorithm": algorithm.as_str(),
455 "publicKeyLen": public_key.len(),
456 })
457}