diff --git a/apps/fluux/src/e2ee/OpenPGPPluginBase.ts b/apps/fluux/src/e2ee/OpenPGPPluginBase.ts
index a08d0bf2..df2abf6d 100644
--- a/apps/fluux/src/e2ee/OpenPGPPluginBase.ts
+++ b/apps/fluux/src/e2ee/OpenPGPPluginBase.ts
@@ -1511,13 +1511,19 @@ export abstract class OpenPGPPluginBase implements E2EEPlugin {
: `${this.pluginName()}: signcrypt does not address ${ownBareJid}`,
)
}
- const skew = Math.abs(envelope.timestamp.getTime() - this.now())
- if (skew > SIGNCRYPT_CLOCK_SKEW_MS) {
- throw new E2EEPluginError(
- 'permanent',
- 'envelope-stale',
- `${this.pluginName()}: signcrypt is ${Math.round(skew / 1000)}s outside the ±7-day skew window`,
- )
+ // Skip timestamp skew check for archived messages (MAM replay,
+ // retryPendingDecrypts). The check is an anti-replay defence that
+ // only makes sense for live messages — archived messages are
+ // authentically old and would always fail the ±7-day window.
+ if (!context?.fromArchive) {
+ const skew = Math.abs(envelope.timestamp.getTime() - this.now())
+ if (skew > SIGNCRYPT_CLOCK_SKEW_MS) {
+ throw new E2EEPluginError(
+ 'permanent',
+ 'envelope-stale',
+ `${this.pluginName()}: signcrypt is ${Math.round(skew / 1000)}s outside the ±7-day skew window`,
+ )
+ }
}
const plaintextBytes = new TextEncoder().encode(envelope.payloadXml)
diff --git a/packages/fluux-sdk/src/core/e2ee/stanzaDecrypt.ts b/packages/fluux-sdk/src/core/e2ee/stanzaDecrypt.ts
index a9bdc5a1..dd105405 100644
--- a/packages/fluux-sdk/src/core/e2ee/stanzaDecrypt.ts
+++ b/packages/fluux-sdk/src/core/e2ee/stanzaDecrypt.ts
@@ -138,11 +138,13 @@ export async function decryptStanzaInPlace(
try {
const messageId = stanza.attrs.id
const isSelfOutgoing = options?.isSelfOutgoing === true
+ const fromArchive = source === 'archive'
const context: InboundDecryptContext | undefined =
- messageId || isSelfOutgoing
+ messageId || isSelfOutgoing || fromArchive
? {
...(messageId && { messageId }),
...(isSelfOutgoing && { isSelfOutgoing: true as const }),
+ ...(fromArchive && { fromArchive: true as const }),
}
: undefined
const target = { kind: 'direct' as const, peer: senderPeer }
diff --git a/packages/fluux-sdk/src/core/e2ee/types.ts b/packages/fluux-sdk/src/core/e2ee/types.ts
index d6edd7c3..15445dd9 100644
--- a/packages/fluux-sdk/src/core/e2ee/types.ts
+++ b/packages/fluux-sdk/src/core/e2ee/types.ts
@@ -175,6 +175,14 @@ export interface InboundDecryptContext {
* (OpenPGP, OMEMO) must branch on this flag.
*/
isSelfOutgoing?: boolean
+ /**
+ * `true` when this stanza was replayed from the XEP-0313 MAM archive
+ * or is being re-decrypted by {@link XMPPClient.retryPendingDecrypts}.
+ * Plugins should relax time-based anti-replay checks (e.g. signcrypt
+ * timestamp skew) for archived messages, since they may be arbitrarily
+ * old yet still authentic.
+ */
+ fromArchive?: boolean
}
/**
diff --git a/packages/fluux-sdk/src/core/modules/MAM.e2ee.test.ts b/packages/fluux-sdk/src/core/modules/MAM.e2ee.test.ts
index 6c6a3b44..d1a7abe1 100644
--- a/packages/fluux-sdk/src/core/modules/MAM.e2ee.test.ts
+++ b/packages/fluux-sdk/src/core/modules/MAM.e2ee.test.ts
@@ -393,6 +393,6 @@ describe('MAM E2EE wiring', () => {
await runQueryWithEntry(harness, PEER, archiveEntry)
const lastCall = decryptSpy.mock.calls[decryptSpy.mock.calls.length - 1]
- expect(lastCall[2]).toEqual({ messageId: 'mam-msg-id' })
+ expect(lastCall[2]).toEqual({ messageId: 'mam-msg-id', fromArchive: true })
})
})