kovra-agent 0.9.0

kovra governed ssh-agent — serves signatures from vault-held keys under the sensitivity policy, keys never leave the vault.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
//! Socket lifecycle for the governed ssh-agent (KOV-13, decision Q4:
//! foreground-only MVP; decision Q5: refuse-and-guide on a pre-existing
//! `$SSH_AUTH_SOCK`).
//!
//! This is the OS edge. On Unix it binds a UNIX socket (mode `0600`), prints the
//! `SSH_AUTH_SOCK` to export, and serves connections in the foreground until
//! Ctrl-C, removing the socket on exit. **The socket peer and a real `ssh`
//! client are `[host]`** — validated on hardware by the human, not asserted by
//! automated tests (CLAUDE.md rule 4). The *protocol* and *session* logic it
//! drives ([`crate::protocol`], [`crate::session`]) are fully mock-tested.
//!
//! On Windows the transport is a **Named Pipe** (`\\.\pipe\kovra-ssh-agent-<fp>`,
//! KOV-61) restricted to the current user via an owner-only security descriptor —
//! the analog of the Unix socket's `0600`. The ssh-agent wire protocol over it is
//! identical, so [`handle_connection`] is shared verbatim; only [`bind`]/[`serve`]
//! differ per OS. Pipes leave no filesystem artifact, so there is nothing to
//! unlink on exit. Validated `[host]` on the HP i7 with a real OpenSSH client
//! (`ssh-add -l`/`-T`, incl. the high/prod confirmation gate).

use std::path::{Path, PathBuf};

use crate::error::AgentError;
#[cfg(any(unix, windows))]
use crate::session::Session;

/// The OS listener the agent serves on: a UNIX-domain socket on Unix, a Named
/// Pipe server on Windows (KOV-61). On any other platform it is a placeholder
/// that is never constructed — [`bind`] errors first.
#[cfg(unix)]
pub use std::os::unix::net::UnixListener as AgentListener;
#[cfg(not(any(unix, windows)))]
pub use stub_impl::AgentListener;
#[cfg(windows)]
pub use windows_impl::AgentListener;

/// Refuse to start if `$SSH_AUTH_SOCK` is already set — we never hijack or chain
/// an existing agent (decision Q5). The caller prints the guidance carried by
/// [`AgentError::AuthSockAlreadySet`].
pub fn ensure_no_existing_agent() -> Result<(), AgentError> {
    if let Some(sock) = std::env::var_os("SSH_AUTH_SOCK") {
        return Err(AgentError::AuthSockAlreadySet(
            sock.to_string_lossy().into_owned(),
        ));
    }
    Ok(())
}

/// A reasonable default socket path under the vault root: `<root>/agent.sock`.
/// (The vault root is already `0700`, so the socket inherits a private parent.)
#[cfg(not(windows))]
pub fn default_socket_path(root: &Path) -> PathBuf {
    root.join("agent.sock")
}

/// On Windows the agent transport is a **named pipe**, which lives in a flat
/// namespace rather than the filesystem. Derive a stable, per-vault pipe name
/// from a fingerprint of the root so distinct vaults don't collide:
/// `\\.\pipe\kovra-ssh-agent-<fp>`.
#[cfg(windows)]
pub fn default_socket_path(root: &Path) -> PathBuf {
    let fp = kovra_core::fingerprint(root.to_string_lossy().as_bytes());
    PathBuf::from(format!(r"\\.\pipe\kovra-ssh-agent-{fp}"))
}

/// Owned session inputs, so `serve`'s closure can build a session per request
/// without lifetime entanglement with the listener loop.
pub struct SessionOwned {
    /// The custodied keys (with private halves).
    pub keys: Vec<crate::session::KeypairEntry>,
    /// The agent scope.
    pub scope: kovra_core::AgentScope,
    /// The confirmer.
    pub confirmer: Box<dyn kovra_core::Confirmer>,
    /// The audit sink.
    pub audit: Box<dyn kovra_core::AuditSink>,
    /// The clock.
    pub clock: Box<dyn kovra_core::Clock>,
    /// The confirmation timeout.
    pub confirm_timeout: std::time::Duration,
    /// The observed requesting process (I16).
    pub requesting_process: Option<String>,
}

#[cfg(any(unix, windows))]
impl SessionOwned {
    fn as_session(&self) -> Session<'_> {
        Session {
            keys: &self.keys,
            scope: &self.scope,
            confirmer: self.confirmer.as_ref(),
            audit: self.audit.as_ref(),
            clock: self.clock.as_ref(),
            confirm_timeout: self.confirm_timeout,
            requesting_process: self.requesting_process.clone(),
        }
    }
}

/// Remove the socket file on shutdown (best-effort). Idempotent. On Windows the
/// transport is a named pipe (no filesystem artifact), so this is a harmless
/// no-op there — the pipe vanishes when its handle closes on exit.
pub fn cleanup(path: &Path) {
    let _ = std::fs::remove_file(path);
}

/// Serve one connection to completion over any byte stream (`Read + Write`): read
/// a frame, dispatch it through a fresh session, write the framed reply, loop
/// until the peer closes. Shared by the Unix-socket and Windows-named-pipe rails
/// — the ssh-agent wire protocol is transport-agnostic. Per-connection errors
/// propagate to the caller, which isolates them (one bad peer must not take the
/// daemon down). A malformed/unknown frame answers `SSH_AGENT_FAILURE` rather
/// than closing (matches the fuzz-target contract: never panic, always a reply).
#[cfg(any(unix, windows))]
fn handle_connection<S, F>(mut stream: S, make_session: &mut F) -> Result<(), AgentError>
where
    S: std::io::Read + std::io::Write,
    F: FnMut() -> Result<SessionOwned, AgentError>,
{
    use crate::protocol::{encode_failure, frame, parse_request, read_frame};

    loop {
        let body = match read_frame(&mut stream)? {
            Some(b) => b,
            None => return Ok(()), // peer closed at a frame boundary
        };
        let reply_body = match parse_request(&body) {
            Ok(request) => {
                let owned = make_session()?;
                let session = owned.as_session();
                session.handle(&request)?
            }
            Err(_) => encode_failure(),
        };
        stream.write_all(&frame(&reply_body))?;
        stream.flush()?;
    }
}

#[cfg(unix)]
pub use unix_impl::{bind, serve};

#[cfg(unix)]
mod unix_impl {
    use std::os::unix::net::UnixListener;
    use std::path::Path;

    use super::{AgentError, SessionOwned, handle_connection};

    /// Bind the agent socket at `path` (mode `0600`), removing a stale socket
    /// file first. Returns the listener; the caller serves it with [`serve`].
    pub fn bind(path: &Path) -> Result<UnixListener, AgentError> {
        // Remove a leftover socket from a previous run (a path that exists but
        // has no live listener). We only ever remove a socket file, never a
        // regular file.
        if path.exists() {
            let is_socket = std::fs::symlink_metadata(path)
                .map(|m| {
                    use std::os::unix::fs::FileTypeExt;
                    m.file_type().is_socket()
                })
                .unwrap_or(false);
            if is_socket {
                let _ = std::fs::remove_file(path);
            } else {
                return Err(AgentError::Socket(format!(
                    "{} exists and is not a socket — refusing to overwrite",
                    path.display()
                )));
            }
        }
        let listener = UnixListener::bind(path)
            .map_err(|e| AgentError::Socket(format!("bind {}: {e}", path.display())))?;
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
                .map_err(|e| AgentError::Socket(format!("chmod {}: {e}", path.display())))?;
        }
        Ok(listener)
    }

    /// Serve the agent in the **foreground** until the listener is closed or an
    /// unrecoverable error occurs. Each accepted connection is handled to
    /// completion (one request → one reply, looping until the peer closes).
    /// Per-connection errors are isolated: a malformed frame answers
    /// `SSH_AGENT_FAILURE` and the connection continues; a transport error drops
    /// just that connection.
    ///
    /// `make_session` is called per request to build a fresh [`super::SessionOwned`]
    /// view over the (possibly re-read) custodied keys.
    pub fn serve<F>(listener: &UnixListener, mut make_session: F) -> Result<(), AgentError>
    where
        F: FnMut() -> Result<SessionOwned, AgentError>,
    {
        for incoming in listener.incoming() {
            match incoming {
                Ok(stream) => {
                    if let Err(e) = handle_connection(stream, &mut make_session) {
                        // Log to stderr and keep serving — one bad peer must not
                        // take the daemon down. No key bytes are ever in `e`.
                        eprintln!("kovra ssh-agent: connection error: {e}");
                    }
                }
                Err(e) => {
                    eprintln!("kovra ssh-agent: accept error: {e}");
                }
            }
        }
        Ok(())
    }
}

#[cfg(windows)]
pub use windows_impl::{bind, serve};

/// Windows Named-Pipe rail for the governed ssh-agent (KOV-61). The pipe is the
/// transport analog of the Unix socket; the ssh-agent wire protocol over it is
/// identical, so [`super::handle_connection`] is reused verbatim. The pipe is
/// restricted to the **current user** (an SDDL `D:P(A;;GA;;;<sid>)` security
/// descriptor — the analog of the socket's `0600`). Named pipes leave no
/// filesystem artifact, so there is nothing to unlink on exit.
#[cfg(windows)]
mod windows_impl {
    use std::os::windows::ffi::OsStrExt;
    use std::os::windows::io::FromRawHandle;
    use std::path::Path;

    use windows::Win32::Foundation::{
        CloseHandle, ERROR_PIPE_CONNECTED, HANDLE, HLOCAL, LocalFree,
    };
    use windows::Win32::Security::Authorization::{
        ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW,
        SDDL_REVISION_1,
    };
    use windows::Win32::Security::{
        GetTokenInformation, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER,
        TokenUser,
    };
    use windows::Win32::Storage::FileSystem::{FILE_FLAGS_AND_ATTRIBUTES, FlushFileBuffers};
    use windows::Win32::System::Pipes::{
        ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_BYTE,
        PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
    };
    use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
    use windows::core::{HRESULT, HSTRING, PCWSTR, PWSTR};

    use super::{AgentError, SessionOwned, handle_connection};

    /// `PIPE_ACCESS_DUPLEX` (read+write server). A `FILE_FLAGS_AND_ATTRIBUTES`.
    const PIPE_ACCESS_DUPLEX: u32 = 0x0000_0003;
    /// Per-instance pipe buffer sizes (advisory).
    const PIPE_BUF: u32 = 8 * 1024;

    fn win(e: impl std::fmt::Display, what: &str) -> AgentError {
        AgentError::Socket(format!("{what}: {e}"))
    }

    /// The Windows listener: owns the wide pipe name, the per-user security
    /// descriptor (kept alive for the pipe's lifetime), and the first server
    /// instance created by [`bind`] (so the pipe exists the moment we publish the
    /// address). Subsequent instances are created per connection by [`serve`].
    pub struct AgentListener {
        name: Vec<u16>,
        sd: PSECURITY_DESCRIPTOR,
        instance: HANDLE,
    }

    // SAFETY: the only non-Send fields are a Windows kernel HANDLE and a
    // security-descriptor heap pointer, both valid and usable from any thread in
    // the process. Moving the listener to the serving thread is therefore sound.
    unsafe impl Send for AgentListener {}

    impl Drop for AgentListener {
        fn drop(&mut self) {
            // The `instance` handle is consumed by `serve` (wrapped in a File that
            // closes it); we only free the security descriptor here. If `serve`
            // never ran, the instance is reclaimed by the OS on process exit.
            if !self.sd.0.is_null() {
                // SAFETY: `sd` came from ConvertStringSecurityDescriptor… (LocalAlloc).
                unsafe {
                    let _ = LocalFree(Some(HLOCAL(self.sd.0.cast())));
                }
            }
        }
    }

    /// Create the pipe (first instance) restricted to the current user.
    pub fn bind(path: &Path) -> Result<AgentListener, AgentError> {
        let name: Vec<u16> = path
            .as_os_str()
            .encode_wide()
            .chain(std::iter::once(0))
            .collect();
        let sd = build_owner_only_sd().map_err(|e| win(e, "pipe security descriptor"))?;
        match create_instance(&name, sd) {
            Ok(instance) => Ok(AgentListener { name, sd, instance }),
            Err(e) => {
                // SAFETY: free the SD we just allocated before bailing.
                unsafe {
                    if !sd.0.is_null() {
                        let _ = LocalFree(Some(HLOCAL(sd.0.cast())));
                    }
                }
                Err(win(
                    e,
                    &format!(
                        "create named pipe {} (another kovra agent already running?)",
                        path.display()
                    ),
                ))
            }
        }
    }

    /// Serve connections in the foreground: accept on the current instance, run
    /// the shared protocol loop over it, then create the next instance. One
    /// connection at a time (foreground MVP, decision Q4).
    pub fn serve<F>(listener: &AgentListener, mut make_session: F) -> Result<(), AgentError>
    where
        F: FnMut() -> Result<SessionOwned, AgentError>,
    {
        // `HANDLE` is Copy; we take the first instance and never let the listener's
        // Drop close it (it is closed here, when the per-connection File drops).
        let mut current = listener.instance;
        loop {
            // Block until a client connects. ERROR_PIPE_CONNECTED means one raced
            // in between create and connect — also success.
            match unsafe { ConnectNamedPipe(current, None) } {
                Ok(()) => {}
                Err(e) if e.code() == HRESULT::from_win32(ERROR_PIPE_CONNECTED.0) => {}
                Err(e) => {
                    eprintln!("kovra ssh-agent: connect error: {e}");
                    // Drop this instance and make a fresh one.
                    unsafe {
                        let _ = CloseHandle(current);
                    }
                    current = create_instance(&listener.name, listener.sd)
                        .map_err(|e| win(e, "re-create pipe instance"))?;
                    continue;
                }
            }

            // Wrap the connected instance as a File (Read + Write); it takes
            // ownership of the handle and closes it on drop. The ssh-agent wire
            // protocol is transport-agnostic, so the shared loop handles it.
            // SAFETY: `current` is a valid, connected pipe handle we own.
            let file = unsafe { std::fs::File::from_raw_handle(current.0.cast()) };
            if let Err(e) = handle_connection(file, &mut make_session) {
                eprintln!("kovra ssh-agent: connection error: {e}");
            }
            // `file` dropped here: flush + disconnect are best-effort niceties; the
            // close already tears the instance down.
            drop_instance(current);

            // Next instance for the next client.
            current = create_instance(&listener.name, listener.sd)
                .map_err(|e| win(e, "create next pipe instance"))?;
        }
    }

    /// Best-effort flush + disconnect (the File drop already closed the handle, so
    /// these may no-op; kept for clarity of the per-connection lifecycle).
    fn drop_instance(handle: HANDLE) {
        // SAFETY: FFI; `handle` was just closed by the File drop — these are
        // best-effort and ignore errors.
        unsafe {
            let _ = FlushFileBuffers(handle);
            let _ = DisconnectNamedPipe(handle);
        }
    }

    /// Create one named-pipe server instance with the owner-only SD.
    fn create_instance(name: &[u16], sd: PSECURITY_DESCRIPTOR) -> windows::core::Result<HANDLE> {
        let sa = SECURITY_ATTRIBUTES {
            nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
            lpSecurityDescriptor: sd.0,
            bInheritHandle: false.into(),
        };
        // SAFETY: FFI; `name` is NUL-terminated and `sa` outlives the call.
        let handle = unsafe {
            CreateNamedPipeW(
                PCWSTR(name.as_ptr()),
                FILE_FLAGS_AND_ATTRIBUTES(PIPE_ACCESS_DUPLEX),
                PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
                PIPE_UNLIMITED_INSTANCES,
                PIPE_BUF,
                PIPE_BUF,
                0,
                Some(&sa),
            )
        };
        if handle.is_invalid() {
            return Err(windows::core::Error::from_thread());
        }
        Ok(handle)
    }

    /// A protected DACL granting full control to **only** the current user — the
    /// Windows analog of the Unix socket's `0600`. Returned descriptor is freed by
    /// [`AgentListener`]'s Drop.
    fn build_owner_only_sd() -> windows::core::Result<PSECURITY_DESCRIPTOR> {
        let sid = current_user_sid_string()?;
        let sddl = HSTRING::from(format!("D:P(A;;GA;;;{sid})"));
        let mut psd = PSECURITY_DESCRIPTOR::default();
        // SAFETY: FFI; `sddl` is a valid wide string; `psd` receives an allocation.
        unsafe {
            ConvertStringSecurityDescriptorToSecurityDescriptorW(
                &sddl,
                SDDL_REVISION_1,
                &mut psd,
                None,
            )?;
        }
        Ok(psd)
    }

    /// The current user's SID as an `S-1-…` string, from the process token.
    fn current_user_sid_string() -> windows::core::Result<String> {
        // SAFETY: FFI; the token handle is closed before returning; buffers are
        // sized by the OS via the two-call pattern.
        unsafe {
            let mut token = HANDLE::default();
            OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)?;
            let mut len = 0u32;
            let _ = GetTokenInformation(token, TokenUser, None, 0, &mut len);
            let mut buf = vec![0u8; len as usize];
            let info = GetTokenInformation(
                token,
                TokenUser,
                Some(buf.as_mut_ptr().cast()),
                len,
                &mut len,
            );
            let _ = CloseHandle(token);
            info?;
            let token_user = &*(buf.as_ptr() as *const TOKEN_USER);
            let mut pwstr = PWSTR::null();
            ConvertSidToStringSidW(token_user.User.Sid, &mut pwstr)?;
            let s = pwstr
                .to_string()
                .map_err(|_| windows::core::Error::from_thread())?;
            // ConvertSidToStringSidW allocates via LocalAlloc.
            let _ = LocalFree(Some(HLOCAL(pwstr.0.cast())));
            Ok(s)
        }
    }
}

#[cfg(not(any(unix, windows)))]
pub use stub_impl::{AgentListener, bind, serve};

#[cfg(not(any(unix, windows)))]
mod stub_impl {
    use std::path::Path;

    use super::{AgentError, SessionOwned};

    const UNSUPPORTED: &str = "the governed ssh-agent is not available on this platform";

    /// Placeholder listener for platforms with neither Unix sockets nor Named
    /// Pipes; never constructed (`bind` errors first).
    #[derive(Debug)]
    pub struct AgentListener(());

    pub fn bind(_path: &Path) -> Result<AgentListener, AgentError> {
        Err(AgentError::Socket(UNSUPPORTED.into()))
    }

    pub fn serve<F>(_listener: &AgentListener, _make_session: F) -> Result<(), AgentError>
    where
        F: FnMut() -> Result<SessionOwned, AgentError>,
    {
        Err(AgentError::Socket(UNSUPPORTED.into()))
    }
}

// In-process Named-Pipe transport test (no real `ssh`). Binds a per-test pipe,
// serves it on a detached thread with an empty key set, then connects a client
// and round-trips a `REQUEST_IDENTITIES` → `IDENTITIES_ANSWER` (0 identities).
// Exercises bind (incl. the owner-only security descriptor), the pipe accept, and
// the shared protocol loop over the pipe. The real `ssh` peer remains `[host]`.
#[cfg(all(test, windows))]
mod windows_tests {
    use std::io::{Read, Write};
    use std::time::Duration;

    use kovra_core::{
        AgentScope, ConfirmOutcome, Filter, MockAuditSink, MockClock, MockConfirmer, Operation,
    };

    use super::{SessionOwned, bind, default_socket_path, serve};
    use crate::protocol::{SSH_AGENT_IDENTITIES_ANSWER, SSH_AGENTC_REQUEST_IDENTITIES, frame};

    #[test]
    fn named_pipe_round_trips_request_identities() {
        let root = tempfile::tempdir().unwrap();
        let pipe = default_socket_path(root.path());
        let listener = bind(&pipe).expect("bind named pipe");

        // Serve on a detached thread; an empty key set needs no confirmation.
        std::thread::spawn(move || {
            let _ = serve(&listener, || {
                Ok(SessionOwned {
                    keys: Vec::new(),
                    scope: AgentScope {
                        operations: [Operation::Metadata, Operation::Inject]
                            .into_iter()
                            .collect(),
                        projects: Filter::Any,
                        environments: Filter::Any,
                    },
                    confirmer: Box::new(MockConfirmer::always(ConfirmOutcome::Approved)),
                    audit: Box::new(MockAuditSink::new()),
                    clock: Box::new(MockClock::default()),
                    confirm_timeout: Duration::from_secs(1),
                    requesting_process: None,
                })
            });
        });

        // The first instance exists from `bind`, so the client connects without a
        // race even if it opens before `serve` calls ConnectNamedPipe.
        let mut client = std::fs::OpenOptions::new()
            .read(true)
            .write(true)
            .open(&pipe)
            .expect("open the agent pipe as a client");
        client
            .write_all(&frame(&[SSH_AGENTC_REQUEST_IDENTITIES]))
            .unwrap();
        client.flush().unwrap();

        let mut len_buf = [0u8; 4];
        client.read_exact(&mut len_buf).unwrap();
        let len = u32::from_be_bytes(len_buf) as usize;
        let mut body = vec![0u8; len];
        client.read_exact(&mut body).unwrap();

        assert_eq!(body[0], SSH_AGENT_IDENTITIES_ANSWER, "answer message type");
        // The identity count (4-byte BE after the type) is zero — no keys custodied.
        assert_eq!(&body[1..5], &[0, 0, 0, 0], "zero identities");
    }
}