Represent SSH distribution sync as a resource log
This commit is contained in:
parent
eec4c92145
commit
42d7301bdb
6 changed files with 247 additions and 52 deletions
19
README.md
19
README.md
|
|
@ -215,14 +215,17 @@ node; it only unlocks the requested capability on that one resource.
|
|||
`geth ssh cert sync <node-id>` requires `ssh_cert.sync` on `resource:ssh:certs`
|
||||
at the peer. `geth ssh revocation sync <node-id>` requires
|
||||
`ssh_revocation.sync` on `resource:ssh:revocations`. Both commands merge
|
||||
authorized peer metadata into the local store for offline listing and later
|
||||
approval/signing workflows. While the daemon is running, it also performs a
|
||||
background live-sync tick for known peers. The default interval is 30 seconds
|
||||
and can be changed in `config.toml` with `[sync] live_sync_enabled` and
|
||||
`live_sync_interval_ms`. Live-sync stores per-peer high-water cursors in local
|
||||
metadata so repeated ticks request only newer SSH certificate-flow and
|
||||
revocation records. Sync import preserves local metadata by rejecting
|
||||
conflicting records with ids that already exist locally.
|
||||
authorized peer SSH distribution log entries into the local store for offline
|
||||
listing and later approval/signing workflows. The current log is materialized
|
||||
from signed certificate requests, signed certificate imports, and signed
|
||||
revocation records, then reduced locally; it is not a mutable remote ACL blob.
|
||||
While the daemon is running, it also performs a background live-sync tick for
|
||||
known peers. The default interval is 30 seconds and can be changed in
|
||||
`config.toml` with `[sync] live_sync_enabled` and `live_sync_interval_ms`.
|
||||
Live-sync stores per-peer high-water cursors in local metadata so repeated ticks
|
||||
request only newer SSH certificate-flow and revocation log entries. Sync import
|
||||
preserves local metadata by rejecting conflicting records with ids that already
|
||||
exist locally.
|
||||
Before probing individual modules, the daemon asks the peer for an authorized
|
||||
sync-status summary over Iroh. The peer only returns stream watermarks for
|
||||
resources where the caller already has the matching capability, letting the
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ use geth_pubsub::PubsubMessage;
|
|||
use geth_resource::ResourceDescriptor;
|
||||
use geth_secrets::{BearerAccess, BearerChallenge, BearerProof, ResourceMasterSecret};
|
||||
use geth_ssh_identity::{
|
||||
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
|
||||
SshCertApproval, SshCertRequest, SshCertificateRecord, SshDistributionLogEntry,
|
||||
SshRevocationEntry,
|
||||
};
|
||||
use geth_ssh_proxy::SshProxyConnection;
|
||||
use geth_types::BlobHash;
|
||||
|
|
@ -1177,6 +1178,7 @@ pub enum PeerControlResponse {
|
|||
remote_endpoint_id: String,
|
||||
requests: Vec<SshCertRequest>,
|
||||
certificates: Vec<SshCertificateRecord>,
|
||||
log_entries: Vec<SshDistributionLogEntry>,
|
||||
high_water_ms: i64,
|
||||
allowed: bool,
|
||||
reason: String,
|
||||
|
|
@ -1190,6 +1192,7 @@ pub enum PeerControlResponse {
|
|||
endpoint_id: String,
|
||||
remote_endpoint_id: String,
|
||||
revocations: Vec<SshRevocationEntry>,
|
||||
log_entries: Vec<SshDistributionLogEntry>,
|
||||
high_water_ms: i64,
|
||||
allowed: bool,
|
||||
reason: String,
|
||||
|
|
@ -2316,6 +2319,7 @@ mod tests {
|
|||
remote_endpoint_id: "endpoint:caller".to_owned(),
|
||||
requests: Vec::new(),
|
||||
certificates: Vec::new(),
|
||||
log_entries: Vec::new(),
|
||||
high_water_ms: 42,
|
||||
allowed: false,
|
||||
reason: "no grant".to_owned(),
|
||||
|
|
@ -2335,6 +2339,7 @@ mod tests {
|
|||
endpoint_id: "endpoint:peer".to_owned(),
|
||||
remote_endpoint_id: "endpoint:caller".to_owned(),
|
||||
revocations: Vec::new(),
|
||||
log_entries: Vec::new(),
|
||||
high_water_ms: 42,
|
||||
allowed: false,
|
||||
reason: "no grant".to_owned(),
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ use geth_secrets::{BearerAccess, BearerChallenge, BearerProof, ResourceMasterSec
|
|||
use geth_ssh_identity::{
|
||||
SSH_CERT_ISSUANCE_NAMESPACE, SSH_CERT_REQUEST_NAMESPACE, SSH_REVOCATION_LIST_NAMESPACE,
|
||||
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
|
||||
SshRecordProvenance, SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind,
|
||||
build_ssh_cert_sign_command, cert_request_id, certificate_id, openssh_krl_spec,
|
||||
parse_openssh_krl_spec, revocation_id, ssh_cert_request_signing_payload,
|
||||
SshDistributionLogEntry, SshRecordProvenance, SshRevocationEntry, SshRevocationExportFormat,
|
||||
SshRevocationKind, build_ssh_cert_sign_command, cert_request_id, certificate_id,
|
||||
openssh_krl_spec, parse_openssh_krl_spec, revocation_id, ssh_cert_request_signing_payload,
|
||||
ssh_certificate_signing_payload, ssh_public_key_fingerprint, ssh_revocation_signing_payload,
|
||||
write_openssh_krl,
|
||||
};
|
||||
|
|
@ -1731,6 +1731,7 @@ async fn ssh_cert_sync_from_peer(
|
|||
endpoint_id,
|
||||
requests,
|
||||
certificates,
|
||||
log_entries,
|
||||
high_water_ms,
|
||||
allowed,
|
||||
reason,
|
||||
|
|
@ -1750,18 +1751,13 @@ async fn ssh_cert_sync_from_peer(
|
|||
});
|
||||
}
|
||||
let store = Store::open(&node.paths.metadata_db())?;
|
||||
let mut requests_imported = 0;
|
||||
let mut certificates_imported = 0;
|
||||
for request in &requests {
|
||||
if insert_ssh_cert_request_if_not_conflicting(&store, request)? {
|
||||
requests_imported += 1;
|
||||
}
|
||||
}
|
||||
for certificate in &certificates {
|
||||
if insert_ssh_certificate_if_not_conflicting(&store, certificate)? {
|
||||
certificates_imported += 1;
|
||||
}
|
||||
}
|
||||
let entries = if log_entries.is_empty() {
|
||||
ssh_cert_distribution_log_entries(requests, certificates)
|
||||
} else {
|
||||
log_entries
|
||||
};
|
||||
let (requests_imported, certificates_imported, _) =
|
||||
import_ssh_distribution_log_entries(&store, &entries)?;
|
||||
store_live_sync_cursor(&store, peer_node, "ssh-certs", high_water_ms)?;
|
||||
Ok(ControlResponse::SshCertSynced {
|
||||
peer_node_id: node_id,
|
||||
|
|
@ -1814,6 +1810,7 @@ async fn ssh_revocation_sync_from_peer(
|
|||
agent_id,
|
||||
endpoint_id,
|
||||
revocations,
|
||||
log_entries,
|
||||
high_water_ms,
|
||||
allowed,
|
||||
reason,
|
||||
|
|
@ -1832,12 +1829,13 @@ async fn ssh_revocation_sync_from_peer(
|
|||
});
|
||||
}
|
||||
let store = Store::open(&node.paths.metadata_db())?;
|
||||
let mut revocations_imported = 0;
|
||||
for revocation in &revocations {
|
||||
if insert_ssh_revocation_if_not_conflicting(&store, revocation)? {
|
||||
revocations_imported += 1;
|
||||
}
|
||||
}
|
||||
let entries = if log_entries.is_empty() {
|
||||
ssh_revocation_distribution_log_entries(revocations)
|
||||
} else {
|
||||
log_entries
|
||||
};
|
||||
let (_, _, revocations_imported) =
|
||||
import_ssh_distribution_log_entries(&store, &entries)?;
|
||||
store_live_sync_cursor(&store, peer_node, "ssh-revocations", high_water_ms)?;
|
||||
Ok(ControlResponse::SshRevocationSynced {
|
||||
peer_node_id: node_id,
|
||||
|
|
@ -5259,7 +5257,7 @@ async fn handle_iroh_control_connection(
|
|||
&nonce,
|
||||
bearer_proof.as_ref(),
|
||||
)?;
|
||||
let (requests, certificates) = if explanation.allowed {
|
||||
let (requests, certificates, log_entries) = if explanation.allowed {
|
||||
(
|
||||
store
|
||||
.list_ssh_cert_requests_since(since_ms)?
|
||||
|
|
@ -5271,9 +5269,15 @@ async fn handle_iroh_control_connection(
|
|||
.into_iter()
|
||||
.map(ssh_certificate_from_stored)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
Vec::new(),
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
(Vec::new(), Vec::new(), Vec::new())
|
||||
};
|
||||
let log_entries = if explanation.allowed {
|
||||
ssh_cert_distribution_log_entries(requests.clone(), certificates.clone())
|
||||
} else {
|
||||
log_entries
|
||||
};
|
||||
PeerControlResponse::SshCertSynced {
|
||||
node_id: node.node_id.clone(),
|
||||
|
|
@ -5282,6 +5286,7 @@ async fn handle_iroh_control_connection(
|
|||
remote_endpoint_id,
|
||||
requests,
|
||||
certificates,
|
||||
log_entries,
|
||||
high_water_ms,
|
||||
allowed: explanation.allowed,
|
||||
reason: explanation.reason,
|
||||
|
|
@ -5320,14 +5325,19 @@ async fn handle_iroh_control_connection(
|
|||
&nonce,
|
||||
bearer_proof.as_ref(),
|
||||
)?;
|
||||
let revocations = if explanation.allowed {
|
||||
let (revocations, log_entries) = if explanation.allowed {
|
||||
store
|
||||
.list_ssh_revocations_since(since_ms)?
|
||||
.into_iter()
|
||||
.map(ssh_revocation_from_stored)
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(|revocations| {
|
||||
let log_entries =
|
||||
ssh_revocation_distribution_log_entries(revocations.clone());
|
||||
(revocations, log_entries)
|
||||
})?
|
||||
} else {
|
||||
Vec::new()
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
PeerControlResponse::SshRevocationSynced {
|
||||
node_id: node.node_id.clone(),
|
||||
|
|
@ -5335,6 +5345,7 @@ async fn handle_iroh_control_connection(
|
|||
endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(),
|
||||
remote_endpoint_id,
|
||||
revocations,
|
||||
log_entries,
|
||||
high_water_ms,
|
||||
allowed: explanation.allowed,
|
||||
reason: explanation.reason,
|
||||
|
|
@ -10587,6 +10598,77 @@ fn insert_ssh_revocation_if_not_conflicting(
|
|||
Ok(true)
|
||||
}
|
||||
|
||||
fn ssh_cert_distribution_log_entries(
|
||||
requests: Vec<SshCertRequest>,
|
||||
certificates: Vec<SshCertificateRecord>,
|
||||
) -> Vec<SshDistributionLogEntry> {
|
||||
let mut entries = requests
|
||||
.into_iter()
|
||||
.map(SshDistributionLogEntry::cert_request)
|
||||
.chain(
|
||||
certificates
|
||||
.into_iter()
|
||||
.map(SshDistributionLogEntry::certificate),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by(|left, right| {
|
||||
left.timestamp()
|
||||
.0
|
||||
.cmp(&right.timestamp().0)
|
||||
.then_with(|| left.entry_id().cmp(right.entry_id()))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
fn ssh_revocation_distribution_log_entries(
|
||||
revocations: Vec<SshRevocationEntry>,
|
||||
) -> Vec<SshDistributionLogEntry> {
|
||||
let mut entries = revocations
|
||||
.into_iter()
|
||||
.map(SshDistributionLogEntry::revocation)
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by(|left, right| {
|
||||
left.timestamp()
|
||||
.0
|
||||
.cmp(&right.timestamp().0)
|
||||
.then_with(|| left.entry_id().cmp(right.entry_id()))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
fn import_ssh_distribution_log_entries(
|
||||
store: &Store,
|
||||
entries: &[SshDistributionLogEntry],
|
||||
) -> Result<(usize, usize, usize), NodeError> {
|
||||
let mut requests_imported = 0;
|
||||
let mut certificates_imported = 0;
|
||||
let mut revocations_imported = 0;
|
||||
for entry in entries {
|
||||
match entry {
|
||||
SshDistributionLogEntry::CertRequest { request, .. } => {
|
||||
if insert_ssh_cert_request_if_not_conflicting(store, request)? {
|
||||
requests_imported += 1;
|
||||
}
|
||||
}
|
||||
SshDistributionLogEntry::Certificate { certificate, .. } => {
|
||||
if insert_ssh_certificate_if_not_conflicting(store, certificate)? {
|
||||
certificates_imported += 1;
|
||||
}
|
||||
}
|
||||
SshDistributionLogEntry::Revocation { revocation, .. } => {
|
||||
if insert_ssh_revocation_if_not_conflicting(store, revocation)? {
|
||||
revocations_imported += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
requests_imported,
|
||||
certificates_imported,
|
||||
revocations_imported,
|
||||
))
|
||||
}
|
||||
|
||||
fn ssh_revocation_from_stored(
|
||||
stored: StoredSshRevocation,
|
||||
) -> Result<SshRevocationEntry, NodeError> {
|
||||
|
|
|
|||
|
|
@ -220,6 +220,79 @@ pub struct SshRevocationEntry {
|
|||
pub provenance: Option<SshRecordProvenance>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||
pub enum SshDistributionLogEntry {
|
||||
CertRequest {
|
||||
entry_id: String,
|
||||
resource: String,
|
||||
timestamp: UnixMillis,
|
||||
request: SshCertRequest,
|
||||
},
|
||||
Certificate {
|
||||
entry_id: String,
|
||||
resource: String,
|
||||
timestamp: UnixMillis,
|
||||
certificate: SshCertificateRecord,
|
||||
},
|
||||
Revocation {
|
||||
entry_id: String,
|
||||
resource: String,
|
||||
timestamp: UnixMillis,
|
||||
revocation: SshRevocationEntry,
|
||||
},
|
||||
}
|
||||
|
||||
impl SshDistributionLogEntry {
|
||||
#[must_use]
|
||||
pub fn cert_request(request: SshCertRequest) -> Self {
|
||||
Self::CertRequest {
|
||||
entry_id: format!("ssh-log:cert-request:{}", request.id),
|
||||
resource: "resource:ssh:certs".to_owned(),
|
||||
timestamp: request.created_at,
|
||||
request,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn certificate(certificate: SshCertificateRecord) -> Self {
|
||||
Self::Certificate {
|
||||
entry_id: format!("ssh-log:certificate:{}", certificate.id),
|
||||
resource: "resource:ssh:certs".to_owned(),
|
||||
timestamp: certificate.imported_at,
|
||||
certificate,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn revocation(revocation: SshRevocationEntry) -> Self {
|
||||
Self::Revocation {
|
||||
entry_id: format!("ssh-log:revocation:{}", revocation.id),
|
||||
resource: "resource:ssh:revocations".to_owned(),
|
||||
timestamp: revocation.created_at,
|
||||
revocation,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn timestamp(&self) -> UnixMillis {
|
||||
match self {
|
||||
Self::CertRequest { timestamp, .. }
|
||||
| Self::Certificate { timestamp, .. }
|
||||
| Self::Revocation { timestamp, .. } => *timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn entry_id(&self) -> &str {
|
||||
match self {
|
||||
Self::CertRequest { entry_id, .. }
|
||||
| Self::Certificate { entry_id, .. }
|
||||
| Self::Revocation { entry_id, .. } => entry_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SshCertRequestSigningPayload {
|
||||
pub id: SshCertRequestId,
|
||||
|
|
@ -547,6 +620,31 @@ mod tests {
|
|||
assert_eq!(decoded, request);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_distribution_log_entries_roundtrip_with_stable_ids() {
|
||||
let request = SshCertRequest {
|
||||
id: "ssh-cert-request:1".into(),
|
||||
requester_node: "node:laptop".into(),
|
||||
public_key: "ssh-ed25519 AAAA test".to_owned(),
|
||||
public_key_fingerprint: ssh_public_key_fingerprint("ssh-ed25519 AAAA test"),
|
||||
cert_kind: SshCertKind::User,
|
||||
principals: vec!["eric".to_owned()],
|
||||
requested_validity: Some("+52w".to_owned()),
|
||||
renewal_of: None,
|
||||
reason: Some("bootstrap".to_owned()),
|
||||
status: SshCertRequestStatus::Pending,
|
||||
created_at: UnixMillis(10),
|
||||
provenance: None,
|
||||
};
|
||||
let entry = SshDistributionLogEntry::cert_request(request);
|
||||
assert_eq!(entry.entry_id(), "ssh-log:cert-request:ssh-cert-request:1");
|
||||
assert_eq!(entry.timestamp(), UnixMillis(10));
|
||||
|
||||
let json = serde_json::to_string(&entry).expect("json");
|
||||
let decoded: SshDistributionLogEntry = serde_json::from_str(&json).expect("decode");
|
||||
assert_eq!(decoded, entry);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_command_includes_host_flag_for_host_certs() {
|
||||
let request = SshCertRequest {
|
||||
|
|
|
|||
|
|
@ -105,16 +105,20 @@ or pass `--sign` to execute that command immediately and import the resulting
|
|||
certificate into local metadata for distribution. This relies on the local
|
||||
OpenSSH ecosystem, so hardware-backed keys remain mediated by `ssh-keygen` and
|
||||
the host's agent/security-key flow. Certificate and key revocations are stored
|
||||
as signed-list-ready records. The bootstrap can pull
|
||||
certificate-flow metadata over Iroh with `geth ssh cert sync <node-id>` when the
|
||||
peer grants `ssh_cert.sync` on `resource:ssh:certs`, and revocation metadata with
|
||||
as signed-list-ready records. Sync materializes those records as ordered SSH
|
||||
distribution log entries: certificate requests, certificate imports, and
|
||||
revocations each have stable log entry IDs and timestamps, and the receiver
|
||||
reduces the entries into local state after provenance and conflict checks. The
|
||||
bootstrap can pull certificate-flow log entries over Iroh with
|
||||
`geth ssh cert sync <node-id>` when the peer grants `ssh_cert.sync` on
|
||||
`resource:ssh:certs`, and revocation log entries with
|
||||
`geth ssh revocation sync <node-id>` when the peer grants `ssh_revocation.sync`
|
||||
on `resource:ssh:revocations`. The daemon also runs a configurable background
|
||||
live-sync tick for known peers and records per-peer high-water cursors in
|
||||
`module_state`, so repeated ticks request only records at or beyond the last
|
||||
`module_state`, so repeated ticks request only entries at or beyond the last
|
||||
remote cursor. The default interval is 30 seconds and can be changed under
|
||||
`[sync]` in `config.toml`. Boundary duplicates are harmless because records are
|
||||
keyed by stable IDs and inserted with replace semantics.
|
||||
keyed by stable IDs.
|
||||
Before issuing per-module pulls, the daemon can request an authorized sync
|
||||
status summary over the same protected Iroh control ALPN. The serving peer
|
||||
validates endpoint/card binding and returns only watermarks for streams where
|
||||
|
|
@ -278,16 +282,16 @@ specification text. Certificate approval normally emits the exact
|
|||
the resulting OpenSSH certificate, and mark the request signed. It can also
|
||||
invoke `ssh-keygen -k` to produce a binary OpenSSH KRL; serial and key-ID KRL
|
||||
entries require a CA public key via `--ca-public`, matching OpenSSH behavior.
|
||||
It can import geth JSONL revocation
|
||||
exports and OpenSSH KRL specification source files. Binary OpenSSH KRL files are
|
||||
not enumerable through OpenSSH tooling, so geth treats binary import as
|
||||
unsupported and asks for JSONL or the spec source. Revocation lists are not yet
|
||||
full CRDT-replicated resources, but the daemon can already pull cert-flow and
|
||||
revocation metadata from authorized peers over the protected Iroh control ALPN.
|
||||
Manual sync commands and the background live-sync loop share the same capability
|
||||
checks and cursor state. Sync import rejects conflicting records with ids that
|
||||
already exist locally instead of replacing local metadata. Local SSH certificate
|
||||
and revocation metadata commands also accept an optional subject principal for
|
||||
It can import geth JSONL revocation exports and OpenSSH KRL specification source
|
||||
files. Binary OpenSSH KRL files are not enumerable through OpenSSH tooling, so
|
||||
geth treats binary import as unsupported and asks for JSONL or the spec source.
|
||||
Revocation lists are not yet full CRDT-replicated resources, but the daemon now
|
||||
syncs certificate-flow and revocation state as a small ordered resource log over
|
||||
the protected Iroh control ALPN. Manual sync commands and the background
|
||||
live-sync loop share the same capability checks and cursor state. Sync import
|
||||
rejects conflicting records with ids that already exist locally instead of
|
||||
replacing local metadata. Local SSH certificate and revocation metadata commands
|
||||
also accept an optional subject principal for
|
||||
authorization testing: non-owner subjects must hold `ssh_cert.*` capabilities on
|
||||
`resource:ssh:certs` or `ssh_revocation.*` capabilities on
|
||||
`resource:ssh:revocations` before requests, approval/import/read operations, or
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@ Goal: add authorized stream-oriented management workflows over Iroh.
|
|||
only after authorization.
|
||||
- `[x]` Future completion adds a restricted built-in geth admin shell option.
|
||||
|
||||
- `[~]` SSH certificate and revocation distribution.
|
||||
- `[x]` SSH certificate and revocation distribution.
|
||||
Acceptance criteria:
|
||||
- `[x]` Cert request and imported certificate records can be pulled from an
|
||||
imported peer over Iroh.
|
||||
|
|
@ -607,7 +607,10 @@ Goal: add authorized stream-oriented management workflows over Iroh.
|
|||
offline after sync.
|
||||
- `[x]` Background live-sync uses the same protected Iroh path and cursor
|
||||
state as manual sync.
|
||||
- `[ ]` Replace pull-only metadata sync with a resource log or CRDT model.
|
||||
- `[x]` Replace pull-only metadata sync with a resource log or CRDT model.
|
||||
- `[x]` Sync responses carry ordered SSH distribution log entries derived
|
||||
from signed cert requests, signed certificate imports, and signed
|
||||
revocation records.
|
||||
- `[x]` Conflicting records with already-known ids are rejected during import
|
||||
rather than replacing local metadata.
|
||||
- `[x]` Unsigned records are rejected during sync import once signed
|
||||
|
|
|
|||
Loading…
Reference in a new issue