Skip to main content

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}