feat: cross-device encryption bootstrap via sync payload

Encrypted sync data now embeds the encryption config so new devices
can self-bootstrap from just the sync data + the password:

Outer envelope (plaintext):
  - salt + verificationBlob — needed to derive key on new device

Inner payload (encrypted):
  - TOTP secret, authMethod, ttlDays, webauthn flag

Flow on new device:
1. Pull encrypted sync data from any channel
2. _decryptFromSync detects no local config, bootstraps from _encConfig
3. User enters password → key derived → payload decrypted
4. Full config (including TOTP secret) restored from inner _encMeta
5. Device is now fully configured — WebAuthn can be registered locally

https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
Claude
2026-03-26 18:44:42 +00:00
parent 51a25a208b
commit 39e609a14e
+62 -4
View File
@@ -351,7 +351,16 @@ const SilentSendSync = {
/**
* Encrypt data for sync if encryption is enabled.
* Returns the original data if encryption is not enabled or key unavailable.
* Embeds the encryption config (salt, TOTP secret, etc.) into the
* encrypted payload so a new device can bootstrap itself from just
* the sync data + the password.
*
* Outer envelope (plaintext): { _ssEncrypted, payload, version, lastModified, _encConfig }
* - _encConfig contains salt and verificationBlob (needed to derive key on new device)
* - Everything else (TOTP secret, settings) is inside the encrypted payload
*
* Inner payload (encrypted): { ...syncData, _encMeta }
* - _encMeta contains the full encryption config including TOTP secret
*/
async _encryptForSync(data) {
const config = await this._getSyncEncryption();
@@ -359,17 +368,33 @@ const SilentSendSync = {
const keyInfo = await this._getEncryptionKey();
if (!keyInfo) {
// Key not available — caller should prompt for auth
return { data: null, encrypted: false, needsAuth: true };
}
const encryptedPayload = await SilentSendCrypto.encryptWithKey(data, keyInfo.key);
// Embed encryption config inside the encrypted payload
// so new devices can bootstrap their local config after decryption
const innerData = {
...data,
_encMeta: {
authMethod: config.authMethod,
ttlDays: config.ttlDays,
webauthn: config.webauthn,
totpSecret: config.totpSecret || null,
},
};
const encryptedPayload = await SilentSendCrypto.encryptWithKey(innerData, keyInfo.key);
return {
data: {
_ssEncrypted: true,
payload: encryptedPayload,
version: data.version,
lastModified: data.lastModified,
// Plaintext bootstrap info — needed to derive the key on a new device
_encConfig: {
salt: config.salt,
verificationBlob: config.verificationBlob,
},
},
encrypted: true,
};
@@ -377,17 +402,50 @@ const SilentSendSync = {
/**
* Decrypt sync data if it's encrypted.
* Returns the original data if not encrypted.
* On a new device (no local encryption config), uses the _encConfig
* from the sync envelope to bootstrap. After decryption, restores
* the full encryption config from the inner _encMeta.
*/
async _decryptFromSync(data) {
if (!data?._ssEncrypted) return { data, decrypted: false };
// If this device has no encryption config yet, bootstrap from the sync envelope
let config = await this._getSyncEncryption();
if (!config?.enabled && data._encConfig) {
// Save minimal config so authenticate() can work
config = {
enabled: true,
salt: data._encConfig.salt,
verificationBlob: data._encConfig.verificationBlob,
authMethod: 'password', // will be updated from _encMeta after decryption
ttlDays: 90,
webauthn: false,
};
await this._saveSyncEncryption(config);
}
const keyInfo = await this._getEncryptionKey();
if (!keyInfo) {
return { data: null, decrypted: false, needsAuth: true };
}
const decrypted = await SilentSendCrypto.decryptWithKey(data.payload, keyInfo.key);
// Restore full encryption config from inner metadata
if (decrypted._encMeta) {
const fullConfig = await this._getSyncEncryption();
if (fullConfig) {
fullConfig.authMethod = decrypted._encMeta.authMethod || fullConfig.authMethod;
fullConfig.ttlDays = decrypted._encMeta.ttlDays ?? fullConfig.ttlDays;
fullConfig.webauthn = decrypted._encMeta.webauthn ?? fullConfig.webauthn;
if (decrypted._encMeta.totpSecret) {
fullConfig.totpSecret = decrypted._encMeta.totpSecret;
}
await this._saveSyncEncryption(fullConfig);
}
delete decrypted._encMeta;
}
return { data: decrypted, decrypted: true };
},