Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/scripts/linux-preview-release.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ process.exit(42);
encoding: 'utf8', env: { ...process.env, PATH: bin,
PROPR_DESKTOP_RPM_PRIVATE_KEY: 'private-test-sentinel', PROPR_DESKTOP_RPM_PASSPHRASE: 'password-test-sentinel',
PROPR_DESKTOP_RPM_PUBLIC_KEY: '-----BEGIN PGP PUBLIC KEY BLOCK-----',
PROPR_DESKTOP_RPM_FINGERPRINT: 'DD4E7F65CE2345A1F4597D643CF856B53093D3A3' },
PROPR_DESKTOP_RPM_FINGERPRINT: '80FE0A7667A92A0AD3CEAE6D4C2FF8C3CC4E1599' },
});
assert.equal(result.status, 1);
assert.deepEqual(JSON.parse(await readFile(evidence, 'utf8')), { key: false, passphrase: false });
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/scripts/rpm-signing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { promisify } from 'node:util';
import { pathToFileURL } from 'node:url';

const execFile = promisify(execFileCallback);
export const OWNER_FINGERPRINT = 'DD4E7F65CE2345A1F4597D643CF856B53093D3A3';
// Owner replacement key effective 2026-10-19; the previous key is historical, not cross-signed.
export const OWNER_FINGERPRINT = '80FE0A7667A92A0AD3CEAE6D4C2FF8C3CC4E1599';
const fingerprintPattern = /^[A-F0-9]{40}$/;

export function requireRpmIdentity(identity) {
Expand Down
75 changes: 67 additions & 8 deletions apps/desktop/scripts/rpm-signing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { test } from 'node:test';
import { inspectSignedPreviewPayload } from './rpm-preview-payload.mjs';
import { OWNER_FINGERPRINT, withKey, assertRpmSignatures, requireRpmIdentity, rpmContentDigest, signRpm, verifyRpm } from './rpm-signing.mjs';
import { OWNER_FINGERPRINT, rpmIdentityFromEnvironment, withKey, assertRpmSignatures, requireRpmIdentity, rpmContentDigest, signRpm, verifyRpm } from './rpm-signing.mjs';
import { linuxPreviewTag, prepareLinuxPreview, readLinuxPreviewBundle, signLinuxPreviewBundle, stageLinuxPreviewDraft, publishLinuxPreviewDraft } from './linux-preview-release.mjs';
import { aptAcceptance } from './apt-auth-acceptance.mjs';
import { authAssets, aptAssets, signMetadata, verifyMetadata, verifyApt } from './linux-package-auth.mjs';
Expand Down Expand Up @@ -138,17 +138,76 @@ test('signed mode requires exact public identity inputs', async () => {
}
});

test('documented owner public export has the exact complete RSA4096 identity and expiry', {
skip: missing.length ? `Missing tools: ${missing}` : false,
}, async () => {
const publicKey = await readFile(join(repositoryRoot, 'docs/static/keys/propr-desktop-release.asc'), 'utf8');
await withKey({ fingerprint: OWNER_FINGERPRINT, publicKey }, async home => {
const PREVIOUS_FINGERPRINT = 'DD4E7F65CE2345A1F4597D643CF856B53093D3A3';
const ownerExport = 'docs/static/keys/propr-desktop-release.asc';
const historicalExport = `docs/static/keys/propr-desktop-release-historical-${PREVIOUS_FINGERPRINT}.asc`;

async function inspectExport(path, fingerprint) {
const publicKey = await readFile(join(repositoryRoot, path), 'utf8');
assert.doesNotMatch(publicKey, /PRIVATE KEY/);
return withKey({ fingerprint, publicKey }, async home => {
const records = run('gpg', ['--homedir', home, '--with-colons', '--list-keys']).split('\n').map(l => l.split(':'));
assert.equal(records.filter(r => r[0] === 'pub').length, 1);
assert.equal(records.filter(r => r[0] === 'sub').length, 0);
assert.equal(run('gpg', ['--homedir', home, '--with-colons', '--list-secret-keys']).trim(), '');
const primary = records.find(r => r[0] === 'pub');
assert.equal(primary[2], '4096');
assert.equal(new Date(Number(primary[5]) * 1000).toISOString(), '2026-10-12T13:07:27.000Z');
assert.equal(new Date(Number(primary[6]) * 1000).toISOString(), '2028-10-11T13:11:25.000Z');
assert.equal(primary[3], '1');
return { publicKey, created: new Date(Number(primary[5]) * 1000).toISOString(),
expires: new Date(Number(primary[6]) * 1000).toISOString(), uids: records.filter(r => r[0] === 'uid').map(r => r[9]) };
});
}

test('documented owner public export has the exact complete RSA4096 identity and expiry', {
skip: missing.length ? `Missing tools: ${missing}` : false,
}, async () => {
assert.equal(OWNER_FINGERPRINT, '80FE0A7667A92A0AD3CEAE6D4C2FF8C3CC4E1599');
const owner = await inspectExport(ownerExport, OWNER_FINGERPRINT);
assert.deepEqual(owner.uids, ['ProPR Desktop Release Signing']);
assert.equal(owner.created, '2026-10-19T14:09:59.000Z');
assert.equal(owner.expires, '2028-10-18T14:09:59.000Z');
// The previous export stays published unchanged as historical public material only.
const previous = await inspectExport(historicalExport, PREVIOUS_FINGERPRINT);
assert.deepEqual(previous.uids, ['ProPR Desktop RPM Signing']);
assert.equal(previous.created, '2026-10-12T13:07:27.000Z');
assert.equal(previous.expires, '2028-10-11T13:11:25.000Z');
// Neither export can stand in for the other under either fingerprint.
await assert.rejects(withKey({ fingerprint: OWNER_FINGERPRINT, publicKey: previous.publicKey }, async () => {}), /pinned, usable RSA/);
await assert.rejects(withKey({ fingerprint: PREVIOUS_FINGERPRINT, publicKey: owner.publicKey }, async () => {}), /pinned, usable RSA/);
});

test('production environment pin accepts only the replacement owner fingerprint with its public export', {
skip: missing.length ? `Missing tools: ${missing}` : false,
}, async t => {
const saved = { fingerprint: process.env.PROPR_DESKTOP_RPM_FINGERPRINT, publicKey: process.env.PROPR_DESKTOP_RPM_PUBLIC_KEY };
t.after(() => {
for (const [name, value] of [['PROPR_DESKTOP_RPM_FINGERPRINT', saved.fingerprint], ['PROPR_DESKTOP_RPM_PUBLIC_KEY', saved.publicKey]]) {
if (value === undefined) delete process.env[name]; else process.env[name] = value;
}
});
const ownerKey = await readFile(join(repositoryRoot, ownerExport), 'utf8');
const previousKey = await readFile(join(repositoryRoot, historicalExport), 'utf8');
const root = await mkdtemp(join(tmpdir(), 'propr-rpm-pin-'));
t.after(() => rm(root, { recursive: true, force: true }));
const disposable = await key(root, 'wrong-signer');
for (const [fingerprint, publicKey] of [[PREVIOUS_FINGERPRINT, previousKey], [PREVIOUS_FINGERPRINT, ownerKey],
[disposable.identity.fingerprint, disposable.identity.publicKey], [OWNER_FINGERPRINT.toLowerCase(), ownerKey],
[OWNER_FINGERPRINT.slice(-16), ownerKey], [undefined, ownerKey]]) {
if (fingerprint === undefined) delete process.env.PROPR_DESKTOP_RPM_FINGERPRINT;
else process.env.PROPR_DESKTOP_RPM_FINGERPRINT = fingerprint;
process.env.PROPR_DESKTOP_RPM_PUBLIC_KEY = publicKey;
assert.throws(() => rpmIdentityFromEnvironment(), /differs from owner pin/);
}
process.env.PROPR_DESKTOP_RPM_FINGERPRINT = OWNER_FINGERPRINT;
process.env.PROPR_DESKTOP_RPM_PUBLIC_KEY = ownerKey;
const identity = rpmIdentityFromEnvironment();
assert.deepEqual(identity, { fingerprint: OWNER_FINGERPRINT, publicKey: ownerKey });
assert.equal(await withKey(identity, async () => 'accepted'), 'accepted');
// A matching pin with mismatched public settings (old export or another signer) fails before signing.
for (const publicKey of [previousKey, disposable.identity.publicKey]) {
process.env.PROPR_DESKTOP_RPM_PUBLIC_KEY = publicKey;
await assert.rejects(withKey(rpmIdentityFromEnvironment(), async () => {}), /pinned, usable RSA/);
}
});

test('signature status parsing ignores filename tokens but requires both pinned signatures', () => {
Expand Down
102 changes: 80 additions & 22 deletions docs/docs/operations/linux-release-key.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,89 @@
# Linux desktop release key

The owner-provisioned Linux desktop release identity is:
The current owner-provisioned Linux desktop release identity, effective for new
Linux previews signed on or after **2026-10-19**, is:

**DD4E7F65CE2345A1F4597D643CF856B53093D3A3**
**80FE0A7667A92A0AD3CEAE6D4C2FF8C3CC4E1599**

[Download the ASCII-armored public key](/keys/propr-desktop-release.asc).
This is public information; the export contains no private key material.

The RSA4096 primary signing key was issued on **2026-10-12** and expires on
**2028-10-11**. Its historical user ID is `ProPR Desktop RPM Signing`; the same
identity authenticates the new DEB/RPM preview checksums and APT repository metadata.
It does not authenticate older unsigned DEBs retroactively.
The RSA4096 primary signing key has user ID `ProPR Desktop Release Signing`. It was
issued on **2026-10-19** and expires on **2028-10-18**. It has no subkeys; the
primary key itself signs. For new previews it signs native RPM headers and
payloads, the release `SHA256SUMS` (as `SHA256SUMS.asc`), and the APT repository
metadata (`APT-InRelease` and `APT-Release.gpg`). DEB payloads are unchanged and
carry no embedded signature; APT authenticates them through the signed metadata
and package hashes. This key does not authenticate older releases retroactively.

Inspect the downloaded export with GnuPG without installing host trust:

```sh
gpg --show-keys --with-colons --with-fingerprint propr-desktop-release.asc
```

Require exactly one `pub` record, RSA4096, and the full fingerprint above in its
following `fpr` record. Compare this notice through independently reviewed project
documentation, not only a key delivered alongside an installer. Reject expired,
revoked, or different keys. See [Linux package authentication](linux-rpm-signing.md)
for checksum verification and scoped APT trust. APT verifies repository metadata
and hashes, not an embedded signature inside each DEB.
Require exactly one `pub` record, RSA4096, no `sub` records, and the full
fingerprint above in its following `fpr` record. Compare this notice through
independently reviewed project documentation, not only a key delivered alongside
an installer. Reject expired, revoked, or different keys. See
[Linux package authentication](linux-rpm-signing.md) for checksum verification and
scoped APT trust. APT verifies repository metadata and hashes, not an embedded
signature inside each DEB.

The owner must keep encrypted offline backups and a revocation certificate under
restricted custody; this notice specifies the required procedure, not an audit of
those backups. Never put private exports, passwords, backup locations, or recovery
material in issues, documentation, artifacts, or CI logs. Test restoration using a
controlled owner process; implementation tests use disposable keys only.
## 2026-10-19 rotation from the previous identity

The previous identity was:

- Fingerprint **DD4E7F65CE2345A1F4597D643CF856B53093D3A3**
- User ID `ProPR Desktop RPM Signing`, RSA4096 primary signing key
- Issued **2026-10-12**, expires **2028-10-11**
- [Historical public export](/keys/propr-desktop-release-historical-DD4E7F65CE2345A1F4597D643CF856B53093D3A3.asc)
(byte-for-byte the export previously published as `propr-desktop-release.asc`)

The owner no longer has the passphrase for the previous private key and generated
a replacement key locally. **No compromise has been reported**, and the previous
key has **not** been revoked. Because the old key cannot sign, the replacement is **not
cross-signed** by it. The transition is instead authenticated only by this
reviewed project documentation and the code pin in the authenticated
`integry/propr` repository: compare the new fingerprint here with the pin in
`apps/desktop/scripts/rpm-signing.mjs` on the reviewed main branch. Do not expect
or accept a "transition statement" signed by the old key.

The previous key signs nothing new after this rotation. Releases that were
signed with it keep their original signatures, assets and trust classification
unchanged; verify them only with the historical export and its own fingerprint.
Verification against the new code pin says nothing about those older releases,
and nothing here re-labels or re-signs them.

### Consumer trust updates

To verify a new preview, obtain the new public export from this notice and confirm
its full fingerprint as above. Then:

- **RPM**: import the new public key into the RPM database you use for ProPR
previews (`sudo rpmkeys --import propr-desktop-release.asc`, or the equivalent
`rpm --import`). `rpmkeys --checksig --verbose` must report both the header and
the header+payload signature with key ID `cc4e1599` (the fingerprint suffix).
Keep the old key only if you still need to verify older RPMs signed with it;
otherwise remove it (for example `sudo rpm -e gpg-pubkey-3093d3a3` on RPM 4.x).
It is not revoked, so removal is housekeeping rather than an emergency.
- **Direct downloads**: verify `SHA256SUMS.asc` in a fresh isolated keyring that
contains only the new export, then check `SHA256SUMS`.
- **APT repository bundle**: replace the source-scoped keyring named in
`signed-by=` with a binary export of the new key (or create a new keyring file
and point the new preview's `deb` line at it). Do not add either key to
`/etc/apt/trusted.gpg` or `trusted.gpg.d`. A repository bundle signed by the new
key fails `apt update` with a keyring containing only the old key; that failure
is expected and must not be overridden.

## Custody, compromise and future rotation

The owner must keep encrypted offline backups, the passphrase, and a revocation
certificate under restricted custody; this notice specifies the required
procedure, not an audit of those backups. Never put private exports, passwords,
backup locations, or recovery material in issues, documentation, artifacts, or CI
logs. Test restoration using a controlled owner process; implementation tests use
disposable keys only.

For a suspected compromise or a key question, contact the repository maintainers
through [integry/propr on GitHub](https://github.com/integry/propr/issues).
Expand All @@ -42,11 +96,15 @@ must stop trusting the affected key; local RPM databases and scoped APT keyrings
do not discover revocations automatically. Consult the latest reviewed notice
before installing.

Before expiry or rotation, publish both complete fingerprints, effective dates,
and the replacement public export, authenticating the transition with the old
key where safe. Review the code pin and public settings together. Preserve
historical notices and immutable assets with their original trust classification;
never relabel them or bypass verification to accommodate expiry.
Before expiry or a future rotation, publish both complete fingerprints, effective
dates, and the replacement public export, authenticating the transition with the
old key where the owner still controls it. When the old key is unavailable, as in
the 2026-10-19 rotation, say so explicitly and rely on reviewed documentation.
Review the code pin and public settings together: the protected
`PROPR_DESKTOP_RPM_PUBLIC_KEY` and `PROPR_DESKTOP_RPM_FINGERPRINT` settings must
match the reviewed pin before any signing run. Preserve historical notices and
immutable assets with their original trust classification; never relabel them or
bypass verification to accommodate expiry.

This notice and download are delivered through the normal reviewed documentation
pipeline. They do not establish a live production APT endpoint.
35 changes: 31 additions & 4 deletions docs/docs/operations/linux-rpm-signing.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# Linux package authentication

New public Linux previews authenticate **both DEB and RPM packages, on x64 and
arm64**. The owner identity is **DD4E7F65CE2345A1F4597D643CF856B53093D3A3**.
See the independent [release-key notice and public download](linux-release-key.md).
arm64**. The owner identity for previews signed on or after 2026-10-19 is
**80FE0A7667A92A0AD3CEAE6D4C2FF8C3CC4E1599** (`ProPR Desktop Release Signing`,
RSA4096, expires 2028-10-18). It replaces
DD4E7F65CE2345A1F4597D643CF856B53093D3A3, whose passphrase is no longer available;
no compromise has been reported, the old key is not revoked, and it has not
cross-signed the new one. See the independent [release-key notice, rotation details and public
downloads](linux-release-key.md).

The main-only `desktop-linux-signed-preview.yml` consumes the exact finalized
four-package artifact from a successful main-dispatched unsigned preview run.
Expand All @@ -25,6 +30,16 @@ Its compatibility names remain unchanged:
| `PROPR_DESKTOP_RPM_PUBLIC_KEY` | Repository variable containing the public export |
| `PROPR_DESKTOP_RPM_FINGERPRINT` | Repository variable with the complete owner fingerprint |

The code pin `OWNER_FINGERPRINT` in `apps/desktop/scripts/rpm-signing.mjs` is the
reviewed source of truth. Signing refuses to start unless
`PROPR_DESKTOP_RPM_FINGERPRINT` equals it exactly, the public export imports as
exactly that one unexpired, unrevoked RSA signing primary key, and the private key
matches it. For the 2026-10-19 rotation a human administrator separately replaces
the protected private-key and passphrase secrets and sets both public settings to
`80FE0A7667A92A0AD3CEAE6D4C2FF8C3CC4E1599` and the export in
`docs/static/keys/propr-desktop-release.asc`. Until then signing fails closed;
there is no fallback to the previous identity.

Private credentials are confined to the protected read-only signing job. The
entry point removes them from the environment before package parsers run. GnuPG
uses temporary private files, scrubbed subprocess environments, exact-primary
Expand Down Expand Up @@ -78,7 +93,10 @@ explicitly authenticates direct DEB downloads; `apt install ./package.deb` alone
does not establish that provenance. RPM users additionally require both native
signatures with `rpmkeys --checksig --verbose`. Use
`dnf --setopt=localpkg_gpgcheck=1` or ordinary `zypper install` after bootstrapping
the confirmed RPM key. Never override a missing key or failed signature.
the confirmed RPM key. After the 2026-10-19 rotation, import the new public key
(both signature lines must report key ID `cc4e1599`); a database holding only the
previous key reports `NOKEY` for new RPMs. Never override a missing key or failed
signature.

## Exported APT repository

Expand All @@ -93,6 +111,8 @@ recovered from InRelease. Use a source-scoped binary or armored public keyring:
deb [signed-by=/absolute/path/propr-desktop-release.gpg] file:/absolute/path/apt-repository preview main
```

After the 2026-10-19 rotation, point `signed-by=` at a keyring containing the new
key only; a keyring holding just the previous key correctly rejects new metadata.
The keyring and repository must be readable by APT. A locally served copy can use
a local HTTP URL with the same scoped keyring. There is no production URL to
configure. Run `apt update` before downloading, and stop on authentication/hash
Expand All @@ -115,6 +135,13 @@ Resume failed staging with the same `propr-desktop-linux-signed-<run-id>` artifa
Do not re-sign or overwrite an existing candidate: signatures can change bytes.
Unavailable inputs require a new explicitly selected source/run, not a fallback.

Rotation does not change history. Any preview already signed by
DD4E7F65CE2345A1F4597D643CF856B53093D3A3 keeps its immutable assets, signatures and
original classification; verify it with the [historical public
export](/keys/propr-desktop-release-historical-DD4E7F65CE2345A1F4597D643CF856B53093D3A3.asc).
The new code pin and key verify only previews signed on or after 2026-10-19 and do
not authenticate older releases.

Unsigned schema 2 remains private validation evidence and cannot be newly
published. Historical schema 3 (`rpm-signed-preview`, `-rpm-signed`) authenticated
RPMs only; its DEBs and claimed checksums were unsigned. Historical immutable
Expand Down Expand Up @@ -187,7 +214,7 @@ key notice. Run from the repository root, with a new output directory:
gh run download "$SIGNING_RUN_ID" \
--name "propr-desktop-linux-signed-$SIGNING_RUN_ID" --dir signed-preview
export PROPR_DESKTOP_RPM_PUBLIC_KEY="$(cat propr-desktop-release.asc)"
export PROPR_DESKTOP_RPM_FINGERPRINT=DD4E7F65CE2345A1F4597D643CF856B53093D3A3
export PROPR_DESKTOP_RPM_FINGERPRINT=80FE0A7667A92A0AD3CEAE6D4C2FF8C3CC4E1599
PROPR_DESKTOP_REAL_LINUX_PACKAGE_ACCEPTANCE=1 \
node apps/desktop/scripts/run-signed-installed-rpm-acceptance.mjs \
--rpm "signed-preview/ProPR-Desktop-$VERSION-linux-x64.rpm" \
Expand Down
Loading
Loading