github NeySlim/ultimate-ca-manager v2.230

11 hours ago

What's Changed

Added

  • Certificate exports in PEM, PKCS#7, PKCS#12/PFX and JKS now omit the self-signed Root CA by default while retaining intermediate certificates, with an explicit Include Root CA option for packaging and import use cases. The option is also honored by account and user-certificate exports; deployment fullchains retain their previous complete-chain behavior. Issuer reconstruction now normalizes AKI/SKI identifiers and verifies signatures, so homonymous CAs and self-issued rollover certificates cannot produce the wrong chain

Security

  • A bundle protected by a one-character password was accepted. The export dialog has required eight since it was written, so the rule only held for someone using the screen; the authority route next to it required four, and was the only one with an upper bound at all. One rule now applies to all six routes and is published so the screen reads it rather than repeating it.
  • Filtering a certificate listing by a status spelled with a capital letter returned every certificate, revoked and valid alike: no branch matched, the condition list stayed empty and the filter was skipped. The same parameter on the user certificate listing returned nothing while reporting an unfiltered total. A status that is not recognised is now refused rather than answered with something else.
  • Importing users from a file silently rewrote an unknown role to the least privileged one, so accounts created for people whose job is to read the audit trail could not read it. The single-user route refuses an unknown role; the import now does too.
  • The machine-readable API description the server serves returned an error, and a second description checked in beside it had frozen two years of releases ago: it named 43 routes of about 370, seven of which no longer exist, and contradicted the code on roles, key algorithms and revocation reasons. The served one works again and is versioned from the release; the frozen one is removed rather than left to be handed to a client generator.
  • A button showing its loading state was not actually disabled: the property that blocks it was set and then overwritten by the caller's own properties, so it only looked blocked. It was still reachable by keyboard and by pressing Enter in the form, which meant a second import, a second deployment binding or a second key export on a double press.
  • Copying a value announced success whether or not anything was copied. The browser's clipboard is unavailable over plain HTTP, which an on-premises installation commonly uses, so the confirmation appeared over an empty clipboard; in one place the opposite happened and a freshly rotated secret, shown once, was reported as failed after it had been saved.
  • A certificate's name was used verbatim to name the file an export is downloaded as, including exports that carry a private key. It is now sanitised.
  • A warning about expiring certificates could appear over the login screen after signing out, and an error from a closed window after it had closed.
  • Two certificate authorities naming each other as parent made the server walk between them for ever. The parent link is a plain text column with nothing to forbid a loop, and ten places followed it, eight of them with no guard at all: exporting such an authority filled the disk with a temporary file, and the worker configuration read at startup could not finish, so the service would not come up. One bounded walk is used everywhere now.
  • A Common Name was treated as a host name whenever it contained a dot, so Example, Inc. was issued as a DNS name and an accented name answered a server error. The rule that decides whether a Common Name grants a DNS identity now requires it to look like a host name.
  • Six listings capped the number of rows from above and not from below, so a negative limit returned everything on SQLite and an error on PostgreSQL. One of them exports the audit trail.
  • A validity of true was accepted as one day rather than refused, since a boolean counts as a number. The enrolment paths already refused it.
  • The legacy OPNsense importer wrote private keys to the database unencrypted where the live route encrypts them.
  • A DNS provider's credentials came back in the response to a connection test, and in the application log. Some providers put their key and token in the URL, and a network error carries that URL into its message, which the test route returned verbatim; only 5 providers of 50 masked anything, and none masked it on the error-response path. Every provider now redacts and bounds what it reports, including tokens obtained at runtime.
  • Creating an ACME account accepted any key type without checking it. Asking for EC-P521 quietly produced a P-256 key, an unknown label produced RSA-2048, and an absurd RSA size was generated for real, which is a way to hang the server. The label is now refused unless it is one the installation supports, as the neighbouring settings route already did, and an RSA key above 8192 bits is refused everywhere.
  • A certificate signing request rejected for a weak hash said only that its signature was invalid on the ACME paths, while the enrolment protocols named the hash. The diagnosis is shared now, and what is parsed is bounded.
  • Exporting a user certificate handed over its private key to anyone allowed to see the certificate: the route asked for no private-key permission and included the key by default, so the operator role could take any user's key and skip the approval-gated key recovery flow. Taking somebody else's key now needs that permission; taking your own is unchanged.
  • Audit entries written outside the audit service were never sealed, and the integrity check stepped over an unsealed entry and restarted its chain there, so it reported a valid ledger across every gap. All eight writers now go through one place that seals the entry, and the check reports what it cannot attest.
  • An audit entry commits the session it is written on, so an entry written before the change it describes decided whether that change survived. Twenty-three places had it that way, in deployment, CRL generation, certificate and authority deletion, ACME, webhooks, single sign-on, Microsoft CA and the database reset; a test now holds the order. Three refusals of the same family remain open where the change is staged inside a called function, one of them reachable by a certificate client.
  • A single sign-on login for a disabled account no longer writes to that account on its way to being refused. The stable external identifier is bound once and decides whose account it is, and binding it while the account was off would have handed that account to whoever held the identifier when it was switched back on.
  • Cloud metadata endpoints were reachable through the guard every outbound request goes through: Oracle Cloud's and Scaleway's were missing from the deny-list, and five of the six ways to write an IPv4 address as IPv6 were not read at all. All six forms are now read, including every layout the NAT64 local-use prefix allows, and a reserved byte an attacker controls no longer switches the check off.
  • The two OPNsense import routes checked one address and connected to another. The port came from the request body unexamined, so 443@169.254.169.254 turned the checked host into a user name; the routes also followed redirects and resolved the name a second time at connection. They now check the address they will request, refuse a redirect, and hold the connection to the addresses that were checked.
  • Restoring a backup behaved differently depending on which page asked. The settings page skipped the migration lock and the pending backend switch, accepted a mode it did not honour, and answered an empty archive as a restore. Everything after the archive is read is now shared between the two pages: recording it, asking for the restart, and what is handed back.
  • Deleting a local ACME account left its authorizations and challenges behind on SQLite and failed outright on PostgreSQL: two of the three deletions compared a protocol identifier, which is text, to a numeric key. All three now match on the identifier, and an authorization is found whether it names the account or only its order.
  • A zone served by the built-in ACME server could be bound to an authority that cannot sign: the locally served mapping checked two of the six reasons an authority is unusable. Issuance then refused the order without failing it, so the client retried for ever. Both mapping tables now ask the same question.
  • Testing whether an OAuth2 authorization endpoint is reachable followed redirects and resolved the address a second time at connection, so the cloud-metadata check saw only the address that was typed. The test now connects to the addresses it checked and does not follow redirects; the status reported is the first answer rather than the end of a chain.
  • An administrator who was not the requester could not collect a recovered key, although the rule is "the requester or an administrator". Two functions of the same name take their arguments in opposite orders and this route called one the way the other expects, so the administrator half answered no to everyone.
  • Seven listing routes did not bound the page size they were asked for. Three had no ceiling, so one request for a million rows loaded the whole table; four had a ceiling and no floor, and a negative row limit means no limit at all on SQLite while PostgreSQL refuses the statement. All seven now clamp at both ends, and a test refuses the next route that does not.
  • Deleting a certificate could leave the row behind with its files already gone: the audit entry was written between detaching the references and committing the deletion, and its own rollback put them back after the disk had changed. The deletion is recorded once it has committed.
  • Changing a certificate authority's CRL schedule was announced as done when the audit entry describing it could not be written, because that entry is what committed the change. The same inversion is corrected in CRL generation, delta CRL generation, installing an externally signed CRL, two ACME paths, single sign-on role synchronisation, and a webhook refusal that persisted the address and signing secret it was refusing.
  • Creating or changing a deployment target was announced as done when the audit entry describing it could not be written, and the answer carried an identifier for a target that is not there. Every route of that page now writes the row first; deleting a target, deleting a binding and running a connection test had the same inversion.
  • The two routes that delete a signing request would delete an issued certificate just as readily: same table, same identifier counter, no check that the row was a request. They also ask for a permission the operator role holds while it does not hold the right to delete certificates, so a valid certificate could leave the instance unrevoked. Both now act only on requests.
  • Applying a managed certificate to HTTPS wrote the stored private key to disk without decrypting it, so on an installation with a key-encryption key the server received ciphertext over a backup of its working key and HTTPS did not come back after the restart. Both paths now read the key the same way, and a key that cannot be read refuses the change.
  • Eight routes announced a success for a write the database had refused: safe_commit answers a pair and these tested the pair itself, which is always true, so the error branch could never run. One of them returned a 201 with the one-time HMAC key of a credential that does not exist. A test now refuses the next call site written that way.
  • A restore could hand back an installation's own secrets in the clear. Five columns keep a secret without a property over them, so nothing re-encrypted what the archive carries: the key of an account at an external certificate authority, the SSH key a deployment authenticates with, a SCEP challenge password, an Intune client secret and a webhook signing secret each went into the database readable, on a server that had them encrypted the minute before it restored its own archive. The manifest now records, for every secret that is a plain column, whether the application reads it as it stands or keeps it encrypted and under which key, and the restore puts each one back the way its column holds it. A secret added later that says neither fails the test suite rather than picking a side on its own
  • The private key of the certificate UCM presents to a Microsoft certificate authority was archived as the source had encrypted it, so a restored connection held a key no key of the target could open and the authority could never be reached again. It travels like the two passwords beside it now, decrypted into the archive and encrypted again with the key of the server restoring it
  • Restoring an SSO provider, an SMTP configuration or a DNS provider wrote its credentials to the column underneath the property that encrypts them: the OAuth2 client secret, the LDAP bind password, the mail account password and the DNS API credentials landed in the database readable, so restoring an archive quietly removed the at-rest protection of the very installation that had taken it, and nothing said so. Every secret now goes back through the property the model declares, which re-encrypts it with the key of the server restoring it
  • Several secrets left an installation as the ciphertext of the installation that wrote them. The export decrypted the layer that holds most of them and not the one that holds the rest, so the upstream ACME account key, its EAB credentials, the SCEP challenge password and the deployment SSH key travelled as values only the source could ever open: the archive looked complete, and the restored integration had no usable credential. Both layers are read when the archive is written, and a secret that does not open at the source stops the backup instead of being carried as if it were the secret
  • A backup archive's header was not covered by its authentication tag: only the four magic bytes were, so the announced format version, the backup type, the UCM version, the creation date and the whole key-derivation profile could be rewritten in place without the archive being refused. Archives are now written in a new container version whose entire canonical header is the authenticated data, and any edit to it is reported as a wrong password or an invalid file. Archives in the previous format are still restored, under every bound described below
  • The key-derivation parameters were read from that unauthenticated header and handed to Argon2id or PBKDF2 unchecked, before anything had been authenticated: a file of a few hundred bytes could ask for terabytes of memory or billions of iterations, and take the worker down with it. A restore now accepts only the profiles UCM emits, each within a fixed range (memory, parallelism, time cost, iterations, output length), and refuses a salt or a nonce of the wrong size, before deriving any key
  • Reading a valid archive could still exhaust the server: its compressed payload was expanded in one go with no ceiling, and its JSON parsed with no limit on nesting or object count, so a hundred kilobytes could become tens of gigabytes. Decompression now runs in bounded chunks, capped both absolutely and relative to the compressed size, and the payload's nesting depth and object count are checked before it is parsed
  • Listing the encrypted backups, downloading one and reading the backup history required only read:settings, a permission the operator role holds, while creating and restoring an archive required admin:system. An archive carries every private key and every secret of the installation, and its password protects the file, not the right to obtain it: the three routes now require admin:system on both the system and the settings API, and the backup tab is hidden from the roles that no longer hold it. The automatic backup switch, its frequency and its retention were missing from the administrator-only settings as well, so an operator could shorten retention through the general settings and let the daily task delete the archives the dedicated route protects
  • A data migration checked whether the target database was empty by counting rows in three tables, one of which has not existed under that name for years, so a target holding certificates, templates, HSM keys or anything else was accepted and written over. Every table of the target is now inspected before anything is created: a single row anywhere, a table belonging to another application, or a UCM table missing columns refuses the migration and names what it found
  • The full database URI, password included, was recorded in the audit trail, in the application log and in the syslog stream on every backend switch and data migration, where every role holding read:audit could read it. The URI is now redacted wherever it is recorded or returned, in the authority as well as in a password= query parameter, and so are the driver errors that quote the URI they failed on
  • Client certificate authentication never engaged on a PostgreSQL installation: the worker read the setting straight from a SQLite file that does not exist there, so the listening socket asked for no client certificate whatever the interface showed. The same read decides the TLS cap Windows enrolment clients need, and both are now read on either backend
  • Turning the ACME server off in the settings turned nothing off: the key was written by the settings page, read back by that page and by the dashboard tile, and consulted by no endpoint under /acme. Every ACME endpoint now answers 503 while it is off, as EST and XCEP already did
  • Deleting a user certificate removed two rows and nothing else: its certificate and key files stayed on disk, and the approval requests, deployment bindings, ACME orders and revocation records kept pointing at a row that was gone, which PostgreSQL refuses outright. It now takes the same path as every other certificate deletion
  • Two bulk delete routes read their list of identifiers without checking it, so a JSON string was iterated character by character and a list of any length became that many transactions and audit entries. Both now use the cap of 100 the other three have always published

Fixed

  • Eight relations were restored as the number they had on the installation the archive came from. The authority that signs a locally served ACME zone, the group owning an SSH certificate, the certificate an ACME order renews and the one it reuses a key from, the role a custom role inherits, the certificate and the request behind a Microsoft CA record, and the account that created an HSM provider each landed on whichever row happened to hold that number on the target: a zone signed by another authority, a certificate owned by another team. Where the column is required, PostgreSQL refused the row and the whole restore with it. All eight carry the identity of the row they name now, and a numeric foreign key that the manifest neither resolves nor explains fails the test suite
  • A required relation the target cannot place ended the restore on a constraint violation naming a table and nothing else. It is now refused before anything is written, naming the section, the column and the row the archive points at but does not carry
  • Two accounts at the same certificate authority under the same mailbox, which UCM allows on purpose, were one row to a restore: they were told apart by nothing but the address and the authority, so the second overwrote the first and any order pointing at the one that disappeared was attached to the one that remained. The name the operator gave each account is part of what identifies it now
  • Restoring as a replacement removed the ACME accounts before the orders that point at them, which PostgreSQL refuses outright. The order the rows are removed in is read from the tables themselves rather than from the relations the archive resolves, which are fewer on purpose
  • The seven notification settings were declared as though the table held a single row, so a restore through the generic path would have applied all seven onto one of them and left the six others as the target had them. They are identified by the kind of notification they configure, which is what the table already requires to be unique
  • Any value identifying a row was read back as a date if it could pass for one, so a template named 20260914 and one named 2026-09-14 were the same row to a restore, as were two spellings of the same serial number: one of the two was lost and the archive's row was applied over the other. Only columns that hold a moment in time are read that way now
  • Nine sections of an archive were put back by hand-written restorers that set a chosen few columns, and only onto rows they were creating. An SSH authority, a Microsoft connector, an HSM provider or key, a scan profile, an issuance policy, an ACME domain or an SSO provider this installation already held was counted as restored and left exactly as it was: it took the archive's name and rules while keeping the authority, the template, the approval group or the DNS provider the target happened to point at, and the WinRM password of a Microsoft connection was on no list at all and was lost at every restore. What those lists did write into a new row, they wrote with the source's own numeric keys, leaving the repair at the end of the restore to correct them: SQLite enforces no foreign key so the repair arrived in time and nobody was any the wiser, while PostgreSQL refused the insert as it happened and took the whole restore with it. All of them now go through the manifest, applied to a row already here exactly as to a new one, with each reference resolved against the rows the same restore has just created
  • An order placed with an external certificate authority came back pointing at the wrong account. The link to the account at that authority is a number, and it was restored as the number the source held, so it landed on whichever account happened to carry it here, or on nothing: the renewal was then attempted against another provider entirely. It travels with the identity of the account it names, like every other relation
  • A configuration an installation holds one of, such as the Active Directory connector, was added beside the one already there instead of replacing it. Its identity is its primary key, which means nothing outside the installation that issued it, so the restore found no row to update; an instance restored twice ended up with two connectors and used whichever the query returned first. The single row is now recognised for what it is
  • The Intune client secret of a SCEP profile read back as no secret at all whenever it was not stored encrypted, which is the case on an installation running without at-rest encryption and on every profile a restore puts back, since the archive carries secrets in the clear so they survive a change of database key. Enrolment through Intune stopped working without a word. The value is now read for what it is, encrypted or not, as the challenge password beside it already was
  • A restore put back six columns of an account and dropped the rest. The manifest declares far more and the export has carried them since the archive became manifest-driven, so an account came back without its MFA secret, without its backup codes, without the SSO identity that binds it to its provider and without the custom role that grants it anything beyond its base rights: the operator signed in again, if they could, to an installation that had quietly forgotten how to verify them. Every column the archive carries is now applied, and a secret goes back where the application reads it from, which for the MFA secret is the column itself
  • Restoring onto an installation that did not already hold the rows an archive points at was impossible. References were resolved against an index of the target taken before the first write, which is the point of building the plan first, but a section can point at a row the same restore creates a few sections earlier: six of them (role permissions, group memberships, WebAuthn credentials, deployment bindings, template pins, key recovery requests) resolved to nothing, and since those columns are required the whole restore died after having written everything before it. The index of the sections a section points at is now rebuilt just before its rows are applied
  • The pass that makes a restore a replacement deleted the rows the restore had just created. It compared the identity the archive carries with the identity the rows have here, and for a section identified by what it points at (a template pin is the pair authority/template, a deployment binding the pair target/certificate, an approval request the pair certificate/date) those are the source's numbers on one side and this installation's on the other: the row was written and removed again, and the section came out of a restore empty without a word. The archived identity is translated through the plan before being compared
  • A section identified partly by a timestamp was restored twice and then emptied: the archive carries a timestamp as an ISO string with a T in it and the database hands back a value whose text form uses a space, so the two never matched, no existing row was ever found to update, and the duplicate was then pruned as a row the archive did not hold
  • Five sections were named as restored and nothing wrote them: the audit log, the discovery results, the Microsoft CA request history, the scan runs and the SCEP request history were exported, counted among the sections applied and never put back. An operator who asks for the history in their archive is asking to get it back
  • The mirror of a private key was written in place, truncating the file before writing it, so a failure partway (a full disk, a read-only mount) destroyed the key that was there while the caller reported a success. It is now written to a temporary file in the same directory, fsynced, and renamed over the mirror. The certificate and CSR files a restore materialises are staged with the rest and published once the transaction has committed, instead of being written in place where a later failure left them holding the archive's content while the rows had gone back
  • A scheduled backup that succeeded could be recorded and audited as a failure. Writing down what the run did was guarded around its commit alone, so a failure in the read or the insert before it escaped to the caller, which reported a backup that is on disk and readable as a failed one, and then recorded that second failure in place of the first. The report is a report: losing it no longer changes what the run actually did
  • A malformed archive was answered differently depending on which restore endpoint it reached, and neither answer said what was wrong with it. The service names the section it refused, the route replaced that with "Invalid restore parameters", and the same file came back as 400 on the system API and as 500 "Restore failed" on the settings API. Both now answer with the refusal the service wrote, and a file too small to be an archive, an archive carrying a number no column can hold, or one whose section is not the shape it should be, are each named for what they are instead of arriving as a server error
  • A row whose identity the archive did not carry was announced as restorable and then broke the insert it could never have satisfied, so the transaction died on a constraint violation with nothing naming the row that did it. Where the column requires a value, such a row is now refused while the plan is being built, before the first write, naming the section and the position; where the column tolerates one, the row is still restored as a new row with the warning it always had, so an installation carrying rows older than the column that identifies them stays restorable
  • A value the column could not hold was restored anyway on SQLite and refused by PostgreSQL, so the same archive succeeded or failed depending on the backend underneath. Integer and numeric columns are now coerced and range-checked like dates and booleans, and a value outside what any backend stores is named with its column
  • An archive carrying nothing but its own metadata was announced as "restored successfully", and the restore then revoked every session and asked for a restart over a file that changed not one row. The answer now says the archive carried no data, and nothing is revoked
  • A restore said nothing about the records it knew could not sign. A private key that is not its certificate's is recorded when the archive is written, deliberately rather than refused, because the state an administrator needs to repair has to be saveable; the restore had that list and reported a clean success anyway, leaving an authority that signs certificates nobody can verify. The list now travels into the result, the log and the answer the operator reads, and archives written before it was carried in the metadata are read from the rows instead
  • Registering both spellings of a zone was refused for locally served domains and accepted for the ones mapped to a DNS provider, where only one of the two could ever be reached and the screen showed both. The DNS-mapped table is guarded the same way now, and a domain the format check would refuse comes back as a bad request rather than as a conflict (#352, reported by @gb-123-git)
  • A domain registered with its wildcard spelling matched nothing at all. Both mapping tables accept *.custom on the way in, and neither could find it again: the lookup removed the wildcard from the domain it was asked about, then compared the result to the stored text, so the entry never matched, not even itself. Orders for that zone were signed by the default CA, validated against the default DNS provider, and challenged despite an auto-approval, while the mapping on screen said otherwise. Both spellings now name the same zone at every level of the hierarchy, the most specific entry decides, and registering the second spelling of a zone that already has one is refused instead of creating an entry nothing would ever reach (#352, reported by @gb-123-git)
  • A data migration between SQLite and PostgreSQL could report success while losing data. A table or a column the target did not have was logged and skipped, and the migration still finished as a success; whole tables were read into memory before a single row was written; the tables were read one after another while the instance kept serving requests, so a certificate issued between two of them arrived without its issuer; the snapshot of the source reported "the dump failed" and "there was nothing to dump" the same way, and the caller checked neither. The migration is now a sequence where every step can stop it: the target must be empty, the source must satisfy the foreign keys the models declare, a snapshot must be taken and read back, the schema must be created and checked, every table and column must be copied, and six checks must pass on the result. Anything that cannot be copied refuses the migration instead of being skipped
  • Nothing verified a migration once it was done. Row counts are now compared table by table against what was read under the snapshot, foreign keys are checked with an anti-join on both backends (the bulk load runs with enforcement off, so nothing was checked as the rows arrived), unique constraints and indexes are checked for values they would have refused, PostgreSQL sequences must not be left behind the data they were reset for, the schema must hold every table and column of the models, and a bounded sample of every encrypted column must still decrypt. Any one of them refuses the switch, and what could not be checked, such as a partial unique index, is named rather than counted as verified
  • The source was read table by table from a live database. It is now read through a single consistent view: a verified snapshot on SQLite, a REPEATABLE READ transaction on PostgreSQL, with rows streamed and inserted in batches so memory does not grow with the size of the installation. Rows written to the instance after that view was fixed cannot be on the target, so the migration counts them and says so instead of reporting a clean success
  • A migration ran with nothing to fall back on. A snapshot of the source is now required and read back before the target is touched: a SQLite copy is taken through SQLite's own backup API and reopened read only, a PostgreSQL dump is listed back with pg_restore, and a snapshot that fails either check is removed rather than left looking like a restore point. Snapshot names carry a UUID and are created exclusively, so two migrations starting in the same second cannot overwrite each other's, and the refusal now carries the reason, a full disk or a pg_dump older than the server it is dumping, instead of a line in a log nobody had reason to read
  • Auth, RBAC, SSO and MFA were copied to a new backend one table per transaction, and the switch was reported as a success as long as the users table alone had made it: an administrator could land on a backend holding their account but none of their group memberships, their custom role or their SSO provider. The copy is now one transaction whose tables are ordered by the target's own foreign keys, so a PostgreSQL role that cannot disable constraints no longer fails on the first child row, and any table that cannot be copied aborts the switch
  • Nothing serialised two backend switches. They now take one lock for the whole operation, and a switch that has written the configuration but whose restart has not happened yet refuses the next one until the service comes back. The lock also covers a restore, which rewrites the entire database a migration would be reading through
  • The configuration file was rewritten in place with a truncating write, so an interruption destroyed the variables the change had nothing to do with, the encryption key among them. It is now written to a temporary file in the same directory, fsynced, given the original mode and owner, renamed into place and read back, with every other line keeping its text and its position; a re-read that does not match, or a restart that cannot be requested, puts the previous content back and refuses the switch. Rotating the session secret goes through the same writer and takes the same lock, since it edits the same file. A database URL containing a character the configuration reader would expand is refused before it is written rather than after the service fails to start with it
  • Under Docker the migration copied the data and returned an instruction. It now returns the same proof the native path acts on before restarting itself: the snapshot, the row counts, the six checks, the columns knowingly left behind with how many values each one held, and the rows that reached the source after the snapshot was taken
  • Backup retention pruned by age alone, which fails in both directions: a fortnight of failed backups with a seven-day retention left nothing to restore from, and daily backups of a growing instance filled the filesystem without ever tripping a date. Three limits now bound it: the most recent archive that still matches what was recorded when it was written is always kept, a minimum number of archives is kept whatever their age (two by default), and an optional ceiling on the room they may take removes the oldest first. None of them protects an archive proven to differ from what was written, and none is enforced to the point of removing the last restore point
  • The backup schedule reported when the task last ran, which said nothing about whether an archive came out of it: a schedule could look healthy with no backup anywhere. It now also reports the outcome of the last unattended run with its reason, and the age of the most recent archive that can be proven whole, both kept across restarts so any worker gives the same answer. The cadence is stated for what it is, an interval since the last run rather than a time of day, in the API and next to the setting
  • A restore wrote as it read: entities were committed before the sections depending on them had even been parsed, files were written halfway through, and a failure in a late section left the instance neither in its previous state nor in the archive's. A restore is now one transaction, decided before it writes: the archive is validated whole, each archived row is matched to the row it is here by a stable identity (a username, a reference, a fingerprint) rather than by the source's numeric id, and only then is anything applied. Files are staged while the transaction runs and published atomically once it has committed, restoring their previous contents if a publication fails partway
  • A restore is a replacement again, as the documentation has always described it: the rows a section held on the source are what it holds afterwards, so restoring after a compromise removes the account, the API key or the client certificate added after the snapshot. Only the sections the archive says it describes are replaced, so an archive of the authorities alone leaves the users and the settings untouched, and an archive carrying no users is refused rather than emptying the account table. A merge mode is available under that name for adding one archive's rows to a live instance
  • Settings kept encrypted at rest, the scheduled-backup password among them, were either skipped by the export or carried as ciphertext bound to the source's database key: a restored installation lost its unattended backups without a word. They now travel in the clear inside the archive, like every other secret, and are re-encrypted with the key of the server restoring them
  • The sections the export learned to carry are now restored too: webhook endpoints, deployment targets and bindings, SCEP profiles, the Active Directory connector, ACME client accounts, role permissions, group memberships, WebAuthn credentials, template pins and key-recovery requests. Their secrets are re-encrypted with the key of the server restoring them, so an archive from another installation arrives usable. An archive from a newer UCM carrying a section this version does not know is reported rather than passed over
  • A restore left the sessions opened before it valid, and the caches holding the certificate authorities, the OCSP responses and the SSO discovery it had just replaced: the previous state kept answering under identities that no longer existed. Restoring now revokes every session, in the database and on disk, clears ten caches that hold identity or PKI state, and asks for the restart the workers need
  • An offline authority's revocation list is signed next to its key and uploaded. The distribution point regenerated it locally on every request instead, taking the generation lock and logging a failure each time; it now serves the list it holds
  • Three pages offered actions the API refuses. Discovery scans and scan profiles require an administrator scope no role carries outside the wildcard, and pinning a template to an authority requires two permissions where the page checked one, so an operator saw buttons that could only answer 403. Re-keying a request as a certificate was offered to principals who cannot write certificates, and led to a page where nothing happened
  • A restore listing the sections an archive holds that this version does not apply joined every one of them into its message, and wrote "sections" for a single one. The list is bounded like the one beside it now
  • One confirmation on the groups page was written in English whatever the interface language
  • Four places still computed "days until expiry" with their own rounding, one day short of the value the rest of the application publishes. The expiry pass was one of them, and it gates its webhook on a positive count, so the last day before a certificate expired emitted nothing at all
  • The browser recomputed the same count in thirteen places, and its shared helper rounded the opposite way from the server, which moved a certificate between the 7-day and 30-day bands depending on which screen showed it
  • Tables did not refresh when somebody else made the change: the toast arrived over a list that still showed the old state until a manual reload. A server event now refreshes the pages that display it, and a socket that reconnects after an outage reloads them rather than staying on what it held
  • Twenty loads that failed were swallowed whole: the panel stayed empty and the console said nothing, so the only way to find out was the network tab. They now leave a trace, without a wall of toasts on a page that fires fourteen loads at once
  • Timestamping, certificate download, SSH host setup and the Windows enrolment endpoints shared one rate-limit bucket with the whole admin API, so a machine client and the interface spent the same quota. Each now has its own, at the rate the other protocol endpoints use
  • Four more pages reload when the change was made elsewhere: signing requests, revocation lists, users and groups. They showed the state they had loaded until somebody pressed refresh
  • A redirect was where a validated outbound request stopped being one: the first host was resolved, checked against the cloud-metadata deny-list and pinned, then the upstream answered 302 and the redirect was followed to an address nobody had looked at. Every hop now goes through the same check, a loop is cut, and the OAuth2 token exchange of a custom mail provider takes that path
  • Following a redirect handed the operator's credentials to whoever answered it: a webhook's bearer token, a mail provider's OAuth2 secret in the body, an integration's key under whatever name it uses. A hop that changes origin is now a bare GET: body, query and every header but a small harmless set are left behind
  • Client certificate authentication also stayed off when the database was briefly unreachable at startup. The last working configuration is kept instead, and the read no longer waits forever on a server that does not answer
  • Reports and exports still counted the days their own way, so a certificate expiring within the day was missing from the PDF and from the expiry pass. Six more places now publish the same number as the rest of the application
  • Revoking, renewing, exporting and signing in bulk had no ceiling on the list of identifiers, where deleting did. All of them cap at a hundred per request, and the screens split a larger selection instead of having it refused
  • Deleting a user certificate withdrew its access to UCM and nothing more: it stayed valid for every other service trusting the same authority, with nothing on the revocation list to say otherwise. It is now revoked on the way out, and a certificate that cannot be revoked is not deleted
  • A backup was written by twenty-seven hand-written exporters, so whatever nobody remembered to add was simply absent from every archive: webhook endpoints, deployment targets and their bindings, SCEP profiles and their Intune settings, the Active Directory connector, ACME client accounts with their upstream key and EAB credentials, the permissions of custom roles, the pins between an authority and its templates, WebAuthn credentials, and, inside sections that were exported, a user's MFA secret, backup codes and SSO identity, an authority's name constraints, policy constraints, path length, CRL cadence, SIA and HSM link, a certificate's subject key and authority key identifiers, UPN name and renewal history, and every OAuth2 credential of the SMTP configuration. What a backup carries is now declared in one manifest, every column of every model is exported unless the manifest records why it is not, and the test suite fails when a model gains a table or a column nobody decided about. On a working installation this recovered seventy-seven webhook endpoints, sixty-one template pins and three ACME client accounts that no previous archive contained. The export is ahead of the restore for the sections that are new, so a restore now names what it carries but does not apply, in its answer and in the log, instead of reporting a complete success
  • Secrets left an installation as ciphertext bound to its database key, so an archive restored elsewhere produced integrations with credentials nobody could read. Each secret is now decrypted as the archive is written, protected by the archive's own encryption, and a secret that cannot be decrypted at the source stops the backup instead of travelling as an unusable value. Relations are exported with the identity of the row they point at (a username, a name, a reference) beside the numeric key that only means something on the source
  • A backup now describes what it holds: the schema version it was written with, the database dialect the source was actually running (the metadata claimed SQLite whatever the server used), the sections it carries with their row counts and a digest each, the sections excluded on purpose, and any record whose stored private key is not its certificate's (recorded rather than refused: a backup has to stay possible precisely when something is wrong). A restore refuses an archive whose schema is newer than the one this server reads, and one whose sections are short of the counts the archive announces, in both cases before writing anything, and says which of the two it was: a refusal used to come back as Invalid restore parameters, which left no way to tell an archive from a newer UCM from a corrupted one. A corrupted archive is now named as such too
  • A backup could be announced as successful while missing what it was meant to hold: any exporter failure, a table that could not be read, an unexpected column or a private key that could not be decrypted, was logged and replaced with an empty section, so the archive silently lost users, certificate authorities or secrets and only said so on the day it was restored. Such a failure now aborts the backup and names the section that failed. A private key that cannot be decrypted is no longer archived in place of the key, where the restore treated it as the key itself and produced an authority that could no longer sign
  • A scheduled backup reported success whatever happened. A creation error, a missing password and a schedule timestamp that could not be saved were all recorded as a completed run, leaving the scheduler view green with no archive anywhere, and a backup password that no longer decrypted was used as the password itself, producing archives that the configured password cannot open. Each run now reports what it did, the scheduler view separates a failed run from one skipped on purpose, and a backup whose timestamp cannot be saved fails instead of repeating every minute until the disk fills
  • Backup retention could delete the last usable archive: it ran on a timer of its own and pruned by age alone, so a long export outage, a lost encryption key or an unset password, ended with no restore point left. Every archive is now read back after it is written and recorded with its size and digest, and retention keeps the most recent archive whose bytes still match that record, whatever its age; archives written before these records existed are kept in the two most recent, since nothing proves them either way. A backup that does not read back as it was written is reported as a failed backup instead of being listed as one more restore point. All backup creation endpoints now honor the configured backup directory rather than forcing the default location
  • The gunicorn access and error logs were never rotated on a Debian or RPM install and grew until the filesystem filled, the access log first on a server answering ACME and SCEP polling. Both packages now install a rotation configuration at /etc/logrotate.d/ucm, daily over fourteen generations, all compressed but the most recent, which asks gunicorn to reopen its files rather than reload it: a reload restarts the worker and drops every open WebSocket. The application log keeps rotating itself as before, on every kind of install, and a container is unaffected since it writes to standard output. The documentation described this file, a retention policy and a database optimiser log that no package had ever shipped (#350, reported by @JoseGoncalves)

📜 Recent release history (last 2 versions)

[2.229] - 2026-09-12

Fixed

  • The service logged two errors at every start, Control server error: [Errno 30] Read-only file system then Control server error: no running event loop, without any other effect. The bundled gunicorn opens a control socket in the working directory by default, and the unit keeps that directory read-only. UCM never uses that interface, so it is now disabled in the gunicorn configuration shared by the Debian package, the RPM and the container image (#349, reported by @JoseGoncalves)
  • The container image carried the whole frontend source tree, its build-time dependencies included, close to 340 MB the server never reads: it serves the built interface only, which is what the image now holds. The image also created its data directories from a brace pattern the shell of its base image does not expand, leaving one oddly named directory that the entrypoint worked around at every start. A package or an image built from a working copy no longer carries what that copy holds outside version control either, the database, the sessions and the private keys under the backend data directory among them

[2.228] - 2026-09-11

Security

  • A certificate request signed through ACME, EST, WSTEP or the Sign CSR action could carry extensions only the issuer may set: its own CRL Distribution Points, Authority Information Access and Certificate Policies replaced the CA's, and a Microsoft SID security extension chosen by the requester was copied onto the certificate (the strong certificate mapping bypass KB5014754 closes). The shared signing path now takes only Subject Alternative Name, Key Usage, Extended Key Usage, Basic Constraints, TLS Feature and OCSP No Check from a leaf request and sets everything else itself, as the SCEP path already did with a stricter list; sub-CA requests signed by an operator are unchanged. A request that already carries the Microsoft template extension or a Must-Staple flag no longer fails with a duplicate extension; Key Usage on a leaf is always critical and Subject Alternative Name is critical when the subject is empty (RFC 5280)
  • Any authenticated user, the read-only viewer included, could download another record's private key through the mTLS PKCS#12 export: the export resolved the certificate by serial number alone, and enrolling a PEM never checked whose certificate it was. Every route that joins an enrolment to a certificate (the mTLS account export, and the user certificate detail, export, revoke and delete routes) now resolves the certificate that was enrolled (byte-identical certificate, else its fingerprint, SHA-1 or SHA-256, else serial and issuer together, the serial read from the certificate itself). Enrolling a certificate that no CA of this server issued is refused, so a serial number cannot be squatted, and the certificate login refuses a certificate whose fingerprint differs from the enrolled one. Importing a user certificate whose key UCM holds is refused unless the caller created that record or is an administrator; assigning an existing certificate, and the list of certificates available for assignment, are limited to one's own records for non-administrators as documented; the mTLS routes validate the validity period strictly, fall back to the configured mTLS CA as intended, and look the CA up by id or reference without a type error on PostgreSQL
  • The approval workflow could be skipped: an error while evaluating the issuance policies (a malformed san value in the request was enough) was logged as non-blocking and the certificate was issued without approval. Policy evaluation now fails closed, and a san value that is not a list of names is refused
  • Every issuance path now refuses to sign outside the issuing CA certificate's own validity window and never issues a certificate expiring after the CA's. Signing a request through EST, WSTEP, ACME or the Sign CSR action did not refuse an expired CA certificate and could issue a certificate outliving it; the user mTLS certificate path ignored a CA taken offline and turned an expired CA into the issuer of a one-day certificate; and no path refused a CA certificate that is not yet valid. The same rule now applies to the issue form (checked before a request could be queued for approval), the approval workflow, SCEP, renewal, sub-CA creation, the timestamp signer and the OCSP responder renewal, with the reason reported instead of a server error
  • The Authority Key Identifier of certificates issued by the issue form, the approval workflow, SCEP, in-place renewal, the timestamp signer and the OCSP responder renewal is now the issuing CA's own Subject Key Identifier, as on the other paths (RFC 5280). It was recomputed from the CA key, so for a CA whose identifier is not the standard hash of its key (an imported CA, an HSM or AD CS generated identifier) the certificate did not chain to its CA for strict validators; a renewal no longer drops the identifier silently on error
  • Protocol renewals now authenticate with the certificate being renewed under one rule shared by EST, SCEP and WSTEP: it must be a certificate this CA issued and still holds, byte for byte, neither revoked (including a certificate revoked and then deleted) nor expired. EST re-enrolment accepted any certificate the TLS layer presented, so a revoked certificate, or one issued by another CA with the same names, was re-enrolled and even kept its extended key usages; SCEP renewal looked the signer up by decimal serial only, so a revoked certificate stored in hexadecimal, or revoked then deleted, could renew. A SCEP renewal must also keep the certificate's Subject Alternative Names, as EST and WSTEP already required. The renewed certificate's record is archived when the new one is issued, so it is no longer picked up again by automatic renewal, a renewal webhook is sent instead of an issuance one, and an EST commit failure is reported instead of being ignored
  • In-place renewal (manual, in bulk and scheduled) now produces what issuance would. It copied every extension of the previous certificate: a Key Usage the key type cannot honour (keyEncipherment on an EC key, corrected at issuance since v2.222) survived renewal, CRL Distribution Points, Authority Information Access and the CPS policy stayed frozen after the CA's URLs changed or its CRL was disabled, and embedded Certificate Transparency timestamps signed for the old certificate were carried over. These are now rebuilt from the CA's configuration, the Key Usage is constrained to the key type, the key must meet the same floor as issuance (a re-signed RSA-1024 was accepted), the Certificate Transparency policy applies, and the certificate starts with the same clock-skew allowance as issuance
  • A certificate whose private key the server does not hold (SCEP, EST, WSTEP and ACME enrolments, imports without a key) is no longer renewed in place: the renewed certificate could never reach the device, and superseding the serial the device still presents got that device refused by OCSP and the CRL. Manual and bulk renewal refuse with the reason, and the scheduler no longer selects such certificates
  • The issuing CA of a certificate whose record does not name it is resolved by verifying the signature, no longer by name: a homonymous CA after a rotation, or a local CA merely sharing the Common Name of an external issuer, could re-sign the certificate. The record is then linked to that CA and the superseded serial is recorded under it, so the previous certificate appears on the right CRL
  • Two concurrent renewals of the same certificate no longer leave a signed certificate that is neither stored nor revocable: the second is refused and asked to reload
  • SCEP issuance applies the same defaults and template rules as the other enrollment paths: a request carrying neither Key Usage nor Extended Key Usage received a certificate with neither, usable for anything; it now gets the TLS defaults. A template bound to a SCEP profile can no longer grant an EKU that hands the enrollee authority over the PKI or a logon identity of its choosing (OCSP signing, timestamping, any purpose, Smartcard Logon), the same rule ACME profiles already enforced: the binding is refused, and a binding saved before this rule never issues such an EKU. The Subject Alternative Name is marked critical when the request has an empty subject (RFC 5280)
  • Issuance paths made consistent with each other: restoring a CA key from an exported file refuses a key that is not the CA certificate's own, as the key import does; a request already holding a certificate is not re-signed in place through the AD CS connector, as the local Sign CSR action refuses it; the CA of an issue or Sign CSR request may be named by id or reference without a type error on PostgreSQL; the CA-only key usages of a template (keyCertSign, cRLSign) are dropped from templates, which describe end-entity certificates, and never land on a certificate issued from an older one; the Certificate Transparency policy (embedded SCTs, ct_required) now applies to the issue form, the approval workflow and SCEP as it did to the other paths
  • ACME: the certificate.issued webhook is sent once per issuance instead of twice; a signing failure the request is not responsible for (CA offline or outside its validity window, signing key unavailable) leaves the order retryable and is reported as serverInternal instead of invalidating it as badCSR; the fallback used when no issuing CA is configured is a CA able to sign, chosen deterministically; an order naming a certificate that a previous order already replaced is refused with alreadyReplaced (RFC 9773)
  • Two requests racing on the same object no longer both issue: signing the same CSR twice, finalizing the same ACME order twice or approving the same request twice from two workers now leaves exactly one certificate, the other request being told to reload
  • Every spelling of an extended key usage in use (the templates page writes msSmartcardLogin, the seeded template and the XCEP policy smartcardLogon) resolves to the same purpose everywhere: a Smartcard Logon template created from the templates page issued no Smartcard Logon over WSTEP, and one written for WSTEP was refused by the issue form; the WSTEP template match and the XCEP policy use the same resolver as issuance, and an unknown name in a template is ignored instead of discarding the others
  • Name constraints now cover every Common Name of a subject (only the first was checked), URI names (by host), directory names (the subject itself, as RFC 5280 prescribes) and UPN names (by domain); a constraint of one of these types used to be ignored when excluding and to refuse everything when permitting
  • Issuance policy rules (allowed key types, maximum number of DNS names, maximum validity) now bind the Sign CSR action, the bulk signing and renewal as they bind the issue form and the approval workflow
  • A certificate issued through the approval workflow now carries everything the request asked for: the subject e-mail address, URI and UPN Subject Alternative Names and OCSP Must-Staple were dropped, and it starts with the same clock-skew allowance as the issue form
  • The certificate presented over mTLS to EST /simpleenroll and /serverkeygen, when the EST CA signed it, must be one the CA still holds, as for re-enrolment: a revoked, superseded or deleted certificate could enrol a new one for any subject (a certificate from another authority the TLS layer trusts is still accepted, RFC 7030). The mTLS login refuses a revoked certificate, whatever the proxy checked, even after the certificate record was deleted. Approving a queued SCEP request from the API now issues its certificate, which the client was polling for indefinitely (with the CA defaults: the profile's template is not applied to a manually approved request), and notifies webhooks of it. A SCEP renewal announces itself as a renewal to webhooks, and a certificate presented before its validity start is refused as such
  • Enrolling a certificate for mTLS login is limited, for non-administrators, to certificates of records they created, whether or not the server holds the key: enrolling another holder's certificate mapped that holder's logins onto the enrolling account and blocked the real holder. A revoked certificate is refused, the same certificate is one enrolment whatever form its serial was stored in, and a certificate forwarded by the proxy is kept with the enrolment
  • SCEP renewal no longer carries over the purposes no enrollee may hold (OCSP signing, timestamping, any purpose, Smartcard Logon) from the previous certificate; a template bound to a SCEP profile validated by Intune may still issue Smartcard Logon, Intune vouching for the requester's identity; WSTEP templates are capped the same way, Smartcard Logon excepted
  • Auto-renewal covers the certificates the server can renew: the default sources now include certificates issued from the form and signed requests, and the settings page offers that source. The CA that really signed a renewed certificate gets the CRL carrying the superseded serial, and among several CA records for one key the one the certificate names is chosen
  • Smaller consistency fixes from the review: the renewed OCSP responder record carries the identifier of its new certificate; an expired CA is skipped by the responder renewal job instead of being counted as a failure at every run; the timestamp signer refuses a CA outside its validity window before loading its key; a TLS Feature copied from a request is never marked critical (RFC 7633); the sub-CA creation names the parent in its refusal
  • An issuance policy that requires approval now binds signing a stored request (alone or in bulk) and renewing a certificate: they are queued for approval like the issue form, and approving them performs the signing or the renewal. Administrators bypass as before; a renewal no renewal could honour is refused at once rather than queued; the bulk operations report the requests they queued; the Approvals page shows what a request is about (type, target, validity, extra key usages). A queued request whose target is signed or renewed directly meanwhile is closed as approved by that action and linked to the certificate; one whose target is deleted or revoked is closed as rejected; approving a request already satisfied closes it on the existing certificate, and approving one of several requests for the same target closes the others as approved by that approver. A request completed by a certificate issued elsewhere, or signed through the Microsoft CA connector, is closed the same way. An intermediate CA signed from a stored request records its signer as creator instead of "system". A hold keeps the renewal request waiting for the unhold; a request past its expiry is closed as expired, never as approved, and stale requests are expired hourly and never listed as pending; the renewal scheduler leaves a certificate whose renewal awaits approval to that decision, as long as it can come before the certificate expires; an expired request no longer counts as pending anywhere (badge, user deletion), approving a request whose target is gone closes it, and a request closed that way or by the deletion or revocation of its target notifies webhooks as for a rejection, a request restored without an expiry gets the standard seven-day lifetime, and a renewal reads the certificate again before deciding, so a revocation committed meanwhile is honoured and a certificate renewed meanwhile is not renewed a second time; a certificate deleted during the renewal batch is skipped instead of ending the batch
  • A SCEP request records the profile it came through: approving it by hand now issues with that profile's template (validity, key usages), as auto-approval does, instead of the CA defaults; the approval is refused when the profile or its template no longer exists, a profile cannot be deleted while requests that came through it are pending, nor a template while a SCEP profile binds it, and a renewal request approved by hand archives the certificate it renews, as auto-approval does. Certificates superseded by a renewal or re-enrolment that kept the old record (SCEP, EST, WSTEP, ACME, OCSP responder) show an "Archived" badge and can be listed with the status filter
  • Development dependency vitest updated to 4.1.11: its mocker could read arbitrary files through a redirect mock (GHSA-82fw-gwwq-j7x9, test tooling only, not shipped in the application)

Changed

  • Upgrade note: auto-renewal no longer renews certificates whose private key the server does not hold (SCEP, EST, WSTEP and ACME enrolments); those devices renew through their protocol. Review the auto-renewal sources after upgrading: the new default adds certificates issued by the server, which is what the scheduler can renew
  • Upgrade note: operators signing stored requests or renewing certificates under a CA covered by an approval policy now see their action queued for approval instead of performed at once
  • Upgrade note: a template bound to a SCEP profile without Intune validation no longer issues OCSP signing, timestamping, any-purpose or Smartcard Logon key usages; such bindings are refused when saved and ignored at issuance

Fixed

  • An approval whose certificate issuance failed (CA offline, HSM unreachable, policy rule violated) was still marked approved, with no certificate and no way to approve it again. The request now stays pending with the reason reported to the approver, who can approve again once the cause is dealt with; a failure to record the issued certificate is reported as such instead of corrupting the response

Full history: CHANGELOG.md


Installation

Docker (Recommended)

# From Docker Hub
docker pull neyslim/ultimate-ca-manager:2.230

# Or from GitHub Container Registry
docker pull ghcr.io/neyslim/ultimate-ca-manager:2.230

# Run
docker run -d -p 8443:8443 \
  -e SECRET_KEY=$(openssl rand -hex 32) \
  --name ucm neyslim/ultimate-ca-manager:2.230

Debian/Ubuntu

wget https://github.com/NeySlim/ultimate-ca-manager/releases/download/v2.230/ucm_2.230_all.deb
sudo dpkg -i ucm_2.230_all.deb
sudo apt-get install -f

Fedora/RHEL

wget https://github.com/NeySlim/ultimate-ca-manager/releases/download/v2.230/ucm-2.230-1.fc43.noarch.rpm
sudo dnf install ./ucm-2.230-1.fc43.noarch.rpm

Silent/Automated Install

# Skip firewall prompts for CI/automation
sudo UCM_PORT=8443 UCM_FIREWALL=no dpkg -i ucm_2.230_all.deb

Default Credentials

  • Username: admin
  • Password: changeme123

Change the password immediately after first login!

Documentation

Don't miss a new ultimate-ca-manager release

NewReleases is sending notifications on new releases.