<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>gen/os — Blog</title><description>Practical notes on Apple device management — Jamf, Intune, Mosyle, scripting, and automation, written up from the field by Philippe Boucher.</description><link>https://genos.blog/</link><language>en</language><item><title>Auto-assigning Macs to a Jamf site from an Entra ID smart group</title><link>https://genos.blog/blog/auto-assign-jamf-site-smart-group-entra-id/</link><guid isPermaLink="true">https://genos.blog/blog/auto-assign-jamf-site-smart-group-entra-id/</guid><description>Jamf sites are static and smart groups don&apos;t move records into them. Here&apos;s a scheduled routine that reads an Entra-tied smart group and assigns each member to the right site through the API.</description><pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I wanted something that sounds like it should be a checkbox: Macs whose user belongs to a particular Entra ID group should land in a specific Jamf &lt;strong&gt;site&lt;/strong&gt;, automatically, so the regional admins only see their own machines. It isn&apos;t a checkbox. A Jamf site is a &lt;em&gt;static&lt;/em&gt; attribute on the computer record, and smart groups don&apos;t move records into sites — they scope policies and profiles, nothing more. So &quot;automatically added to a site&quot; really means: something runs on a schedule, reads the group&apos;s members, and writes the site onto each one through the API.&lt;/p&gt;
&lt;p&gt;Here&apos;s the routine I use to do exactly that.&lt;/p&gt;
&lt;h2&gt;Step 1: the Entra-tied smart group&lt;/h2&gt;
&lt;p&gt;First you need a smart group whose membership reflects an Entra ID group. Two ways to get there — confirm which one matches your setup:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Jamf connected to Entra as a cloud identity provider, with an assigned user on each computer.&lt;/strong&gt; The smart group criterion then keys off the assigned user&apos;s Entra group membership. This is the cleanest path if you&apos;ve already wired Jamf to Entra.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An extension attribute that surfaces the user&apos;s Entra group&lt;/strong&gt; — populated by a script (a Microsoft Graph lookup, or reading what Platform SSO / the Company Portal already knows locally). The smart group criterion keys off that EA&apos;s value.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Either way you end up with a smart group — say &lt;strong&gt;&quot;Entra – Marketing&quot;&lt;/strong&gt; — that fills and empties as people move in and out of the Entra group. Note its &lt;strong&gt;group ID&lt;/strong&gt; (it&apos;s in the URL when you open the group in Jamf). That ID is the only handle the routine needs.&lt;/p&gt;
&lt;h2&gt;Step 2: why this needs a routine at all&lt;/h2&gt;
&lt;p&gt;Worth saying plainly, because it&apos;s the thing that trips people up: there is no native &quot;members of this smart group go in this site&quot; setting. Site is assigned per-record, by hand or by API. So the job is a small loop — &lt;em&gt;get the group&apos;s members, PUT the site onto each&lt;/em&gt; — that you run on a schedule. That loop is the &quot;routine.&quot;&lt;/p&gt;
&lt;h2&gt;Step 3: the routine&lt;/h2&gt;
&lt;p&gt;A bash version you can drop into cron, a LaunchDaemon, or an Azure Function:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash
# Sync Jamf site assignment from an Entra-tied smart group.
# Run on a schedule. Idempotent — re-running just re-asserts the same state.

set -euo pipefail

JAMF_URL=&quot;https://yourorg.jamfcloud.com&quot;
CLIENT_ID=&quot;xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx&quot;
CLIENT_SECRET=&quot;…&quot;           # from an API Role/Client (see privileges below)
SMART_GROUP_ID=&quot;42&quot;          # the Entra-tied smart group
TARGET_SITE=&quot;Marketing&quot;      # the Jamf site to drop members into

# --- 1. bearer token (modern OAuth client-credentials) ---------------------
token=$(curl -fsS -X POST &quot;$JAMF_URL/api/oauth/token&quot; \
  -H &quot;Content-Type: application/x-www-form-urlencoded&quot; \
  -d &quot;client_id=$CLIENT_ID&quot; \
  -d &quot;client_secret=$CLIENT_SECRET&quot; \
  -d &quot;grant_type=client_credentials&quot; \
  | plutil -extract access_token raw -)

auth=(-H &quot;Authorization: Bearer $token&quot;)

# --- 2. read smart group membership (Classic API — bearer works here) ------
ids=$(curl -fsS &quot;${auth[@]}&quot; -H &quot;Accept: application/xml&quot; \
  &quot;$JAMF_URL/JSSResource/computergroups/id/$SMART_GROUP_ID&quot; \
  | xmllint --xpath &apos;//computers/computer/id/text()&apos; - 2&amp;gt;/dev/null)

# --- 3. assign each member to the target site ------------------------------
for id in $ids; do
  echo &quot;Assigning computer $id → site &apos;$TARGET_SITE&apos;&quot;
  curl -fsS -X PUT &quot;${auth[@]}&quot; -H &quot;Content-Type: application/xml&quot; \
    &quot;$JAMF_URL/JSSResource/computers/id/$id&quot; \
    -d &quot;&amp;lt;computer&amp;gt;&amp;lt;general&amp;gt;&amp;lt;site&amp;gt;&amp;lt;name&amp;gt;${TARGET_SITE}&amp;lt;/name&amp;gt;&amp;lt;/site&amp;gt;&amp;lt;/general&amp;gt;&amp;lt;/computer&amp;gt;&quot; \
    &amp;gt;/dev/null
done

echo &quot;Done — $(echo &quot;$ids&quot; | wc -w | tr -d &apos; &apos;) computers reconciled.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What&apos;s going on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Modern auth.&lt;/strong&gt; Basic auth is gone on recent Jamf Pro, so the routine gets an OAuth bearer token from an &lt;strong&gt;API Role/Client&lt;/strong&gt; at &lt;code&gt;/api/oauth/token&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bearer tokens work on the Classic API.&lt;/strong&gt; Membership and site assignment are still cleanest on the Classic endpoints, and the modern token authorizes them fine — so you get one auth model across both.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;GET /JSSResource/computergroups/id/&amp;lt;id&amp;gt;&lt;/code&gt;&lt;/strong&gt; returns the group&apos;s members; the &lt;code&gt;xmllint --xpath&lt;/code&gt; pulls out just the computer IDs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;PUT /JSSResource/computers/id/&amp;lt;id&amp;gt;&lt;/code&gt;&lt;/strong&gt; with a minimal &lt;code&gt;&amp;lt;computer&amp;gt;&amp;lt;general&amp;gt;&amp;lt;site&amp;gt;&lt;/code&gt; body sets the site. You only send the field you&apos;re changing.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;The API role privileges&lt;/h3&gt;
&lt;p&gt;Keep the client least-privilege. It needs exactly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Read&lt;/strong&gt; on Smart Computer Groups (to list members)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Update&lt;/strong&gt; on Computers (to set the site)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Nothing else.&lt;/p&gt;
&lt;h2&gt;The same thing as a Claude Code routine&lt;/h2&gt;
&lt;p&gt;If you&apos;ve already stood up a Jamf MCP server, you don&apos;t need the bash at all. Schedule a Claude Code routine that runs every few hours and tells the MCP, in plain language: &lt;em&gt;&quot;find the members of the &apos;Entra – Marketing&apos; smart group and set each one&apos;s site to Marketing.&quot;&lt;/em&gt; Same loop, same API calls underneath — but the routine is a sentence, and you get a readable run log instead of cron output. It&apos;s the natural follow-on to wiring an assistant to Jamf in the first place.&lt;/p&gt;
&lt;h2&gt;Caveats worth knowing&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A computer lives in exactly one site.&lt;/strong&gt; Assigning a new site replaces the old one — make sure your groups don&apos;t overlap, or decide which wins.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;This is one-way.&lt;/strong&gt; When someone leaves the Entra group the Mac drops out of the smart group, but nothing moves it &lt;em&gt;back&lt;/em&gt; out of the site. If you need that, add reconciliation: compare current site members against group members and reset the strays to your default site.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It&apos;s idempotent.&lt;/strong&gt; Re-running re-asserts the same assignment, so a frequent schedule is safe — the PUTs are no-ops when nothing changed.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;Site assignment can&apos;t be driven by a smart group directly, but it&apos;s a short hop with the API: read the Entra-tied group, PUT the site onto each member, run it on a schedule. Whether that schedule is cron or a Claude routine, the Macs sort themselves into the right site as people move around the org — and the regional admins only ever see what&apos;s theirs.&lt;/p&gt;
</content:encoded><category>jamf</category><category>entra</category><category>api</category><category>automation</category><author>hello@genos.blog (Philippe Boucher)</author></item><item><title>A self-updating pkg: install the latest app version from Azure Blob in a postinstall</title><link>https://genos.blog/blog/evergreen-pkg-postinstall-azure-blob/</link><guid isPermaLink="true">https://genos.blog/blog/evergreen-pkg-postinstall-azure-blob/</guid><description>Stop re-packaging an app on every release. Build a payload-free pkg once whose postinstall downloads the current installer from Azure Blob Storage — replacing one blob ships a new version to everyone.</description><pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Re-packaging an app every time the vendor ships an update gets old fast: download the new version, build the pkg, upload it to Jamf, fix the scope, test. Do that across a dozen apps and you spend real time pushing bytes around.&lt;/p&gt;
&lt;p&gt;Here&apos;s the pattern I use instead. I build &lt;strong&gt;one&lt;/strong&gt; pkg that contains no application at all — just a &lt;code&gt;postinstall&lt;/code&gt; script. When that pkg runs on a Mac, the script reaches out to Azure Blob Storage, pulls down the &lt;em&gt;current&lt;/em&gt; installer, and runs it. The pkg I upload to Jamf never changes. To ship a new version of the app, I overwrite a single blob in the container. Every deployment after that gets the latest build, with no re-packaging and no touching Jamf.&lt;/p&gt;
&lt;h2&gt;The shape of the trick&lt;/h2&gt;
&lt;p&gt;Two pieces:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;payload-free pkg&lt;/strong&gt; — a package with zero files, whose only job is to run a script.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;&lt;code&gt;postinstall&lt;/code&gt; script&lt;/strong&gt; that downloads the real installer from blob storage and installs it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That&apos;s it. The &quot;latest version&quot; lives in the bucket, not in the package.&lt;/p&gt;
&lt;h2&gt;Building the payload-free pkg&lt;/h2&gt;
&lt;p&gt;Put the script in a &lt;code&gt;scripts/&lt;/code&gt; folder named exactly &lt;code&gt;postinstall&lt;/code&gt;, make it executable, and hand the folder to &lt;code&gt;pkgbuild&lt;/code&gt; with &lt;code&gt;--nopayload&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p scripts
# (write the postinstall below into scripts/postinstall first)
chmod +x scripts/postinstall

pkgbuild --nopayload \
  --scripts scripts \
  --identifier com.yourorg.acme-bootstrap \
  --version 1.0 \
  Acme-Bootstrap.pkg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--nopayload&lt;/code&gt; tells &lt;code&gt;pkgbuild&lt;/code&gt; there are no files to lay down — the package exists only to run the scripts in &lt;code&gt;scripts/&lt;/code&gt;. The &lt;code&gt;postinstall&lt;/code&gt; runs as root at install time, which is exactly what you want for dropping an app into &lt;code&gt;/Applications&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If you deploy this outside Jamf as well, sign it so Gatekeeper and &lt;code&gt;installer&lt;/code&gt; trust it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;productsign --sign &quot;Developer ID Installer: Your Org (TEAMID)&quot; \
  Acme-Bootstrap.pkg Acme-Bootstrap-signed.pkg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside Jamf, signing isn&apos;t strictly required — the management framework installs it directly — but it&apos;s good hygiene.&lt;/p&gt;
&lt;h2&gt;The postinstall script&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash
# postinstall — download the current installer from Azure Blob and install it.
# Ships inside a payload-free &quot;bootstrap&quot; pkg.
# Replace the blob in Azure = ship a new version. This pkg never changes.

set -euo pipefail

# --- config -----------------------------------------------------------------
# Read-only, container-scoped, expiring SAS. It only grants pulling an
# installer we&apos;d hand out anyway — low sensitivity, but keep it read-only.
BLOB_URL=&quot;https://YOURACCOUNT.blob.core.windows.net/installers/Acme-latest.pkg&quot;
SAS=&quot;?sv=2024-11-04&amp;amp;ss=b&amp;amp;srt=o&amp;amp;sp=r&amp;amp;se=2027-01-01T00:00:00Z&amp;amp;sig=REDACTED&quot;
# ----------------------------------------------------------------------------

workdir=&quot;$(mktemp -d)&quot;
trap &apos;rm -rf &quot;$workdir&quot;&apos; EXIT
pkg=&quot;$workdir/installer.pkg&quot;

echo &quot;Downloading the current installer…&quot;
if ! curl -fsSL &quot;${BLOB_URL}${SAS}&quot; -o &quot;$pkg&quot;; then
  echo &quot;ERROR: download failed&quot; &amp;gt;&amp;amp;2
  exit 1
fi

# A failed/expired SAS returns an XML error page, not a pkg. Catch that before
# handing garbage to installer.
if ! file &quot;$pkg&quot; | grep -qi &apos;xar archive&apos;; then
  echo &quot;ERROR: downloaded file is not a valid pkg (check the SAS/URL)&quot; &amp;gt;&amp;amp;2
  exit 1
fi

echo &quot;Installing…&quot;
installer -pkg &quot;$pkg&quot; -target /

exit 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few deliberate choices in there:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;mktemp -d&lt;/code&gt; + a &lt;code&gt;trap&lt;/code&gt;&lt;/strong&gt; so the download lands in a unique temp dir and gets cleaned up no matter how the script exits.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;curl -fsSL&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;-f&lt;/code&gt; fails the command on an HTTP error instead of happily saving the error body; quote the whole URL because the SAS string is full of &lt;code&gt;&amp;amp;&lt;/code&gt; and &lt;code&gt;=&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;file … xar archive&lt;/code&gt; check.&lt;/strong&gt; This is the one that saves you a support ticket: when a SAS expires or the URL is wrong, Azure hands back an XML error document. Without the check you&apos;d pass that to &lt;code&gt;installer&lt;/code&gt; and get a confusing failure. The check turns it into a clear log line.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Hosting the installer in Azure Blob&lt;/h2&gt;
&lt;p&gt;Create a &lt;strong&gt;private&lt;/strong&gt; container (don&apos;t make it public unless you mean to), then overwrite one fixed blob name on every release:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;az storage blob upload \
  --account-name YOURACCOUNT \
  --container-name installers \
  --name Acme-latest.pkg \
  --file ./Acme-3.4.1.pkg \
  --overwrite
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Macs always pull &lt;code&gt;Acme-latest.pkg&lt;/code&gt;; you decide what that name points at. Shipping 3.4.2 next month is one &lt;code&gt;upload --overwrite&lt;/code&gt; away.&lt;/p&gt;
&lt;h3&gt;The SAS token&lt;/h3&gt;
&lt;p&gt;Generate a &lt;strong&gt;read-only&lt;/strong&gt;, container-scoped SAS with an expiry — never an account key:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;az storage container generate-sas \
  --account-name YOURACCOUNT \
  --name installers \
  --permissions r \
  --expiry 2027-01-01 \
  --auth-mode login --as-user \
  --output tsv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Yes, that token ships inside the pkg. I&apos;m comfortable with that because it grants exactly one thing — &lt;em&gt;reading an installer I&apos;d otherwise hand to anyone on the fleet&lt;/em&gt; — and nothing else: no write, no listing the account, no other container. To rotate it without rebuilding the pkg, back the SAS with a &lt;strong&gt;stored access policy&lt;/strong&gt; and revoke the policy server-side. Otherwise, rotating means issuing a new SAS and rebuilding the bootstrap pkg once.&lt;/p&gt;
&lt;h2&gt;Deploying it&lt;/h2&gt;
&lt;p&gt;Upload &lt;code&gt;Acme-Bootstrap.pkg&lt;/code&gt; to Jamf once and scope a policy to it however you like — at enrollment, in Self Service, on a schedule. From then on the package is frozen. The only thing that ever changes is the blob.&lt;/p&gt;
&lt;h2&gt;Not on Azure?&lt;/h2&gt;
&lt;p&gt;The pattern is storage-agnostic — only the URL and the auth differ:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AWS S3&lt;/strong&gt; — host the installer in a bucket and download via a &lt;strong&gt;presigned URL&lt;/strong&gt; (or a public object). Same &lt;code&gt;curl … | installer&lt;/code&gt; shape.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Any HTTPS host&lt;/strong&gt; — Backblaze B2, Cloudflare R2, even a plain web server. If &lt;code&gt;curl&lt;/code&gt; can reach it, the script doesn&apos;t care.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And if you outgrow a single fixed name — say you want version history and rollback — upload versioned files (&lt;code&gt;Acme-3.4.1.pkg&lt;/code&gt;) plus a tiny &lt;code&gt;Acme.json&lt;/code&gt; manifest that names the current one. The script reads the manifest first, then downloads what it points to. More moving parts, but you keep every build.&lt;/p&gt;
&lt;h2&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;Packaging doesn&apos;t have to mean re-packaging. Build the wrapper once — payload-free pkg, a &lt;code&gt;postinstall&lt;/code&gt; that pulls from blob storage — and your &quot;update the app&quot; workflow shrinks to a single &lt;code&gt;az storage blob upload --overwrite&lt;/code&gt;. The pkg in Jamf becomes a stable pointer, and the latest version lives where it&apos;s easy to replace.&lt;/p&gt;
</content:encoded><category>jamf</category><category>packaging</category><category>azure</category><category>scripting</category><author>hello@genos.blog (Philippe Boucher)</author></item><item><title>Why Jamf encrypted script parameters break on modern macOS</title><link>https://genos.blog/blog/jamf-encrypted-parameters-libressl/</link><guid isPermaLink="true">https://genos.blog/blog/jamf-encrypted-parameters-libressl/</guid><description>A policy that worked for years suddenly logs &apos;bad magic number&apos; after a macOS update. Here&apos;s why LibreSSL breaks Jamf&apos;s encrypted script parameters — and the openssl flags that fix it for good.</description><pubDate>Thu, 18 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A policy that had run fine for a year started failing on freshly-upgraded Macs. The script used Jamf&apos;s Encrypted Script Parameters to stash an API account password, and on the new machines every Jamf API call came back as the &quot;Unauthorized&quot; HTML login page — so the script blew up trying to parse XML. The real culprit was one line of &lt;code&gt;openssl&lt;/code&gt; output buried at the top: &lt;code&gt;bad magic number&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;The setup&lt;/h2&gt;
&lt;p&gt;Encrypted Script Parameters is the standard way to keep a secret out of a Jamf script in cleartext. You encrypt the secret once with &lt;code&gt;openssl&lt;/code&gt;, store the encrypted string, the salt, and the passphrase as script parameters, and the script decrypts the secret at runtime. The decrypt line looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pass=$(echo &quot;$4&quot; | openssl enc -aes256 -d -a -A -S &quot;$5&quot; -k &quot;$6&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Parameter positions vary from script to script — the log-collection script I hit this on used different ones — but the shape is always the same: &lt;code&gt;-S &quot;$salt&quot; -k &quot;$passphrase&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;The symptom&lt;/h2&gt;
&lt;p&gt;On macOS Ventura and later, that same decrypt returns:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bad magic number
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;prepended to (or instead of) the password. So &lt;code&gt;$pass&lt;/code&gt; ends up empty or garbage. Every authenticated &lt;code&gt;curl&lt;/code&gt; to the Jamf API then returns the login/Unauthorized HTML page instead of XML, and any &lt;code&gt;xmllint&lt;/code&gt; / &lt;code&gt;xpath&lt;/code&gt; parsing dies with something like &lt;code&gt;mismatched tag&lt;/code&gt; from the XML parser.&lt;/p&gt;
&lt;p&gt;This is what makes the bug a time-sink: it &lt;em&gt;looks&lt;/em&gt; like an API or permissions problem, but it&apos;s a decryption problem. The credentials were never the issue.&lt;/p&gt;
&lt;h2&gt;Why it breaks&lt;/h2&gt;
&lt;p&gt;Check your &lt;code&gt;openssl&lt;/code&gt; version on an old machine and a new one:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;openssl version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The encrypted blob in my case was generated years earlier on &lt;strong&gt;LibreSSL 2.8.3&lt;/strong&gt; (Monterey). Back then, passing an explicit salt with &lt;code&gt;-S&lt;/code&gt; wrote the raw ciphertext &lt;strong&gt;without&lt;/strong&gt; the &lt;code&gt;Salted__&lt;/code&gt; header that &lt;code&gt;openssl&lt;/code&gt; normally puts at the front. &lt;strong&gt;LibreSSL 3.x&lt;/strong&gt; (Ventura and up — I was on 3.3.6) &lt;strong&gt;expects that &lt;code&gt;Salted__&lt;/code&gt; header at decryption time, even when you also pass &lt;code&gt;-S&lt;/code&gt;.&lt;/strong&gt; No header, no decryption: &lt;code&gt;bad magic number&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You can confirm it directly. Decode the blob and look at the first eight bytes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo &quot;$ENCRYPTED&quot; | openssl base64 -d -A | xxd | head -1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If they aren&apos;t &lt;code&gt;53 61 6c 74 65 64 5f 5f&lt;/code&gt; — that&apos;s &lt;code&gt;Salted__&lt;/code&gt; in ASCII — your blob has no header, and modern LibreSSL won&apos;t decrypt it with &lt;code&gt;-S&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;The fix: stop using -S&lt;/h2&gt;
&lt;p&gt;The robust, version-independent approach is to let &lt;code&gt;openssl&lt;/code&gt; manage the salt itself. Encrypt &lt;strong&gt;without&lt;/strong&gt; &lt;code&gt;-S&lt;/code&gt;, so the salt and header are embedded in the output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo &quot;my-secret&quot; | openssl enc -aes256 -a -A -k &quot;$PASSPHRASE&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and decrypt &lt;strong&gt;without&lt;/strong&gt; &lt;code&gt;-S&lt;/code&gt;, reading the salt back from that embedded header:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pass=$(echo &quot;$ENCRYPTED&quot; | openssl enc -aes256 -d -a -A -k &quot;$6&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The separate salt parameter is now unnecessary — you can drop it entirely. Because the salt travels &lt;em&gt;inside&lt;/em&gt; the ciphertext, this behaves the same on every LibreSSL version you&apos;ll meet in the field. Re-encrypt your secrets once in this format, update the script and the policy&apos;s parameters, and upgrade-day breakage goes away for good.&lt;/p&gt;
&lt;h2&gt;If you can&apos;t re-encrypt right now&lt;/h2&gt;
&lt;p&gt;Maybe that blob is wired into a dozen policies and you just need today&apos;s run to work. As long as you still have the salt and passphrase, you can rebuild the header &lt;code&gt;openssl&lt;/code&gt; is looking for and decrypt the old blob as-is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ printf &apos;Salted__&apos;; printf &apos;%s&apos; &quot;$SALT&quot; | xxd -r -p; \
  echo &quot;$ENCRYPTED&quot; | openssl base64 -d -A; } \
  | openssl enc -aes256 -d -k &quot;$PASSPHRASE&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That prepends &lt;code&gt;Salted__&lt;/code&gt; plus the 8-byte salt to the header-less ciphertext — exactly what modern LibreSSL wants. Treat it as a bridge, then migrate to the no-&lt;code&gt;-S&lt;/code&gt; format when you have a moment.&lt;/p&gt;
&lt;h2&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;bad magic number&lt;/code&gt; from &lt;code&gt;openssl enc&lt;/code&gt; almost always means the ciphertext header doesn&apos;t match what your current &lt;code&gt;openssl&lt;/code&gt; expects — usually because it was encrypted on a different version. In Jamf land, the fix is to stop pinning the salt with &lt;code&gt;-S&lt;/code&gt; and let &lt;code&gt;openssl&lt;/code&gt; embed it. Encrypt once in the portable format, and your encrypted parameters stop being a macOS-upgrade landmine.&lt;/p&gt;
</content:encoded><category>jamf</category><category>scripting</category><category>openssl</category><category>macos</category><author>hello@genos.blog (Philippe Boucher)</author></item><item><title>Zero-touch deployment, explained for non-IT people</title><link>https://genos.blog/blog/zero-touch-deployment-explained/</link><guid isPermaLink="true">https://genos.blog/blog/zero-touch-deployment-explained/</guid><description>What actually happens when a new hire&apos;s MacBook configures itself out of the box — and what it takes to set that up with Apple Business Manager.</description><pubDate>Mon, 08 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The best onboarding experience I can build for a client looks like this: a new employee receives a sealed MacBook box at home, opens it, connects to Wi-Fi, signs in with their work account — and twenty minutes later the machine is encrypted, secured, and loaded with every app they need. Nobody from IT touched it.&lt;/p&gt;
&lt;p&gt;That&apos;s &lt;strong&gt;zero-touch deployment&lt;/strong&gt;, and it&apos;s not magic. Here&apos;s what&apos;s happening behind the scenes.&lt;/p&gt;
&lt;h2&gt;The three pieces&lt;/h2&gt;
&lt;h3&gt;Apple Business Manager&lt;/h3&gt;
&lt;p&gt;When your organization buys devices from Apple or an authorized reseller, those devices can be automatically registered to your &lt;strong&gt;Apple Business Manager&lt;/strong&gt; (ABM) account at the time of purchase. ABM is the bridge between Apple and your management system: it knows which devices belong to your company before they&apos;re even unboxed.&lt;/p&gt;
&lt;h3&gt;The MDM&lt;/h3&gt;
&lt;p&gt;Your MDM — Jamf, Intune, or Mosyle — is linked to ABM. The moment a registered device starts up and touches the internet, Apple tells it: &lt;em&gt;&quot;You belong to this organization; enroll yourself with their MDM before doing anything else.&quot;&lt;/em&gt; The employee can&apos;t skip this, and a thief can&apos;t bypass it. Even wiped, the device re-enrolls.&lt;/p&gt;
&lt;h3&gt;The configuration&lt;/h3&gt;
&lt;p&gt;This is where the real work lives — and where setups succeed or fail. The MDM pushes everything the device needs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Disk encryption&lt;/strong&gt; (FileVault) enforced from first boot&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security baseline&lt;/strong&gt;: passcode rules, firewall, automatic OS updates&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Identity&lt;/strong&gt;: sign-in with the employee&apos;s existing work account&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Apps&lt;/strong&gt;: deployed silently, licensed through Apple&apos;s volume purchasing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The welcome experience&lt;/strong&gt;: what the employee actually sees during setup&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why it pays for itself&lt;/h2&gt;
&lt;p&gt;The math is straightforward. Manually setting up a Mac takes IT one to three hours per machine — imaging, app installs, settings, the works. With zero-touch, marginal device setup time drops to roughly zero, and every machine is configured &lt;em&gt;identically&lt;/em&gt;, which is what auditors and cyber-insurance questionnaires actually want to see.&lt;/p&gt;
&lt;p&gt;Offboarding gets the same upgrade: when someone leaves, one click locks or wipes the device remotely, and re-issuing it to the next hire is just another unboxing.&lt;/p&gt;
&lt;h2&gt;What it takes to set up&lt;/h2&gt;
&lt;p&gt;For a typical small or mid-sized organization, a zero-touch foundation is a short, fixed-scope project: ABM enrollment and domain verification, MDM configuration, security baseline, app catalog, and a test run with a pilot device before the rollout. After that, it just runs.&lt;/p&gt;
&lt;p&gt;If your IT person is still spending afternoons setting up laptops by hand, that&apos;s the project to do next.&lt;/p&gt;
</content:encoded><category>apple-business-manager</category><category>zero-touch</category><category>onboarding</category><author>hello@genos.blog (Philippe Boucher)</author></item><item><title>Jamf, Intune, or Mosyle? Choosing the right MDM for your Apple fleet</title><link>https://genos.blog/blog/jamf-intune-or-mosyle/</link><guid isPermaLink="true">https://genos.blog/blog/jamf-intune-or-mosyle/</guid><description>The three platforms I deploy most often, compared honestly — and the questions that actually decide which one is right for your organization.</description><pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When an organization asks me which MDM they should use for their Macs, iPhones, and iPads, they usually expect a one-word answer. The honest answer is: it depends on three questions, and none of them are about the MDM itself.&lt;/p&gt;
&lt;h2&gt;The three questions that actually matter&lt;/h2&gt;
&lt;h3&gt;1. What does the rest of your stack look like?&lt;/h3&gt;
&lt;p&gt;If your organization lives in &lt;strong&gt;Microsoft 365&lt;/strong&gt; — Entra ID for identity, conditional access policies, a Windows fleet alongside the Macs — then &lt;strong&gt;Intune&lt;/strong&gt; deserves a serious look. You already pay for it in most Microsoft 365 plans, your security team already knows the console, and compliance signals flow straight into conditional access.&lt;/p&gt;
&lt;p&gt;If you&apos;re &lt;strong&gt;Apple-first or Google Workspace-based&lt;/strong&gt;, Intune&apos;s advantages shrink quickly, and the friction of its Apple support (slower profile delivery, a weaker macOS story) starts to show.&lt;/p&gt;
&lt;h3&gt;2. How much depth do you need?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Jamf&lt;/strong&gt; is the deepest Apple management platform, full stop. If you need complex smart groups, sophisticated deployment workflows, identity-based login with Jamf Connect, or endpoint security with Jamf Protect, nothing else matches it. The trade-off is cost and the need for someone who knows it well.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mosyle&lt;/strong&gt; covers the most common 80% of needs at a fraction of the price, and its automation is genuinely good. For a 30-person startup that needs solid security baselines and zero-touch enrollment, Mosyle is often the rational choice.&lt;/p&gt;
&lt;h3&gt;3. Who will run it after I leave?&lt;/h3&gt;
&lt;p&gt;This is the question consultants skip, and it&apos;s the most important one. A perfectly configured Jamf instance that nobody on your team understands becomes shelf-ware within a year. Part of my job is matching the platform to the people who will own it — and leaving documentation they&apos;ll actually use.&lt;/p&gt;
&lt;h2&gt;My short version&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Microsoft-centric org, mixed fleet&lt;/strong&gt; → Intune&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Apple-heavy org that needs depth and polish&lt;/strong&gt; → Jamf&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Smaller team, strong value focus&lt;/strong&gt; → Mosyle&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There are exceptions to all three, which is exactly what an intro call is for.&lt;/p&gt;
</content:encoded><category>jamf</category><category>intune</category><category>mosyle</category><category>mdm</category><author>hello@genos.blog (Philippe Boucher)</author></item></channel></rss>