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
16 changes: 12 additions & 4 deletions apps/desktop/scripts/check-native.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ function tryRequire(mod) {
})
if (res.status === 0) return { ok: true }
const err = (res.stderr || '').trim()
const match = err.match(/NODE_MODULE_VERSION\s+(\d+).+?this version of Node\.js requires\s+NODE_MODULE_VERSION\s+(\d+)/s)
const match = err.match(
/NODE_MODULE_VERSION\s+(\d+).+?this version of Node\.js requires\s+NODE_MODULE_VERSION\s+(\d+)/s
)
return { ok: false, compiledAbi: match?.[1], expectedAbi: match?.[2], err }
}

Expand All @@ -42,10 +44,14 @@ if (failures.length === 0) {
process.exit(0)
}

console.error(`[check:native] NODE_MODULE_VERSION mismatch — ${failures.length} module(s) failed to load under ${currentRuntime}:`)
console.error(
`[check:native] NODE_MODULE_VERSION mismatch — ${failures.length} module(s) failed to load under ${currentRuntime}:`
)
for (const f of failures) {
if (f.compiledAbi && f.expectedAbi) {
console.error(` - ${f.mod}: compiled for ABI ${f.compiledAbi}, runtime needs ABI ${f.expectedAbi}`)
console.error(
` - ${f.mod}: compiled for ABI ${f.compiledAbi}, runtime needs ABI ${f.expectedAbi}`
)
} else {
console.error(` - ${f.mod}: ${f.err.split('\n')[0]}`)
}
Expand All @@ -54,7 +60,9 @@ for (const f of failures) {
const fix = stamp === 'electron' ? 'pnpm rebuild:node' : 'pnpm rebuild:electron'
const altFix = stamp === 'electron' ? 'pnpm rebuild:electron' : 'pnpm rebuild:node'
console.error('')
console.error(`[check:native] stamp says last build target was "${stamp}"; current runtime is "${currentRuntime}".`)
console.error(
`[check:native] stamp says last build target was "${stamp}"; current runtime is "${currentRuntime}".`
)
console.error(`[check:native] fix:`)
console.error(` ${fix} # to run tests/scripts under Node`)
console.error(` ${altFix} # to run the Electron app (pnpm dev)`)
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/main/calendar/google/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { createHash, randomBytes } from 'node:crypto'
import { shell } from 'electron'
import { z } from 'zod'
import { createLogger } from '../../lib/logger'
import type { DataDb } from '../../database/types'
import { listCalendarSources } from '../repositories/calendar-sources-repository'
import {
clearGoogleCalendarTokens,
getGoogleCalendarTokens,
Expand Down Expand Up @@ -374,6 +376,12 @@ export async function hasGoogleCalendarLocalAuth(): Promise<boolean> {
return typeof refreshToken === 'string' && refreshToken.trim().length > 0
}

export async function hasGoogleCalendarConnection(db: DataDb): Promise<boolean> {
if (!(await hasGoogleCalendarLocalAuth())) return false
const accounts = listCalendarSources(db, { provider: 'google', kind: 'account' })
return accounts.length > 0
}

export function buildGoogleCalendarAuthUrl(input: {
clientId: string
redirectUri: string
Expand Down
47 changes: 44 additions & 3 deletions apps/desktop/src/main/calendar/google/sync-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,22 @@ vi.mock('electron', () => ({
}))

vi.mock('./oauth', () => ({
hasGoogleCalendarLocalAuth: vi.fn(async () => true)
hasGoogleCalendarLocalAuth: vi.fn(async () => true),
hasGoogleCalendarConnection: vi.fn(async () => true)
}))

import { hasGoogleCalendarLocalAuth } from './oauth'
vi.mock('../../sync/auth-state', () => ({
isMemryUserSignedIn: vi.fn(async () => true)
}))

import { hasGoogleCalendarConnection, hasGoogleCalendarLocalAuth } from './oauth'
import { isMemryUserSignedIn } from '../../sync/auth-state'
import {
applyGoogleCalendarDelete,
applyGoogleCalendarWriteback,
syncLocalSourceToGoogleCalendar,
pushSourceToGoogleCalendar,
syncGoogleCalendarNow,
syncGoogleCalendarSource
} from './sync-service'

Expand All @@ -55,6 +62,8 @@ describe('google calendar sync service', () => {
db = dbResult.db
mockCalendarSend.mockClear()
vi.mocked(hasGoogleCalendarLocalAuth).mockResolvedValue(true)
vi.mocked(hasGoogleCalendarConnection).mockResolvedValue(true)
vi.mocked(isMemryUserSignedIn).mockResolvedValue(true)

const seeded = seedTestData(db)
projectId = seeded.projectId
Expand Down Expand Up @@ -648,7 +657,7 @@ describe('google calendar sync service', () => {

it('skips local Google reconciliation when the device is not connected', async () => {
seedGoogleCalendarSource()
vi.mocked(hasGoogleCalendarLocalAuth).mockResolvedValue(false)
vi.mocked(hasGoogleCalendarConnection).mockResolvedValue(false)

db.insert(tasks)
.values({
Expand Down Expand Up @@ -684,4 +693,36 @@ describe('google calendar sync service', () => {
expect(client.upsertEvent).not.toHaveBeenCalled()
expect(client.deleteEvent).not.toHaveBeenCalled()
})

describe('syncGoogleCalendarNow gating', () => {
function buildClient() {
return {
listCalendars: vi.fn(),
createCalendar: vi.fn(),
listEvents: vi.fn(),
upsertEvent: vi.fn(),
deleteEvent: vi.fn()
}
}

it('skips all Google API calls when Memry user is not signed in', async () => {
vi.mocked(isMemryUserSignedIn).mockResolvedValue(false)
const client = buildClient()

await syncGoogleCalendarNow(db, { client })

expect(client.listCalendars).not.toHaveBeenCalled()
expect(client.listEvents).not.toHaveBeenCalled()
})

it('skips all Google API calls when no Google Calendar connection exists', async () => {
vi.mocked(hasGoogleCalendarConnection).mockResolvedValue(false)
const client = buildClient()

await syncGoogleCalendarNow(db, { client })

expect(client.listCalendars).not.toHaveBeenCalled()
expect(client.listEvents).not.toHaveBeenCalled()
})
})
})
17 changes: 9 additions & 8 deletions apps/desktop/src/main/calendar/google/sync-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
enqueueLocalSyncUpdate
} from '../../sync/local-mutations'
import { publishProjectionEvent } from '../../projections'
import { hasGoogleCalendarLocalAuth } from './oauth'
import { hasGoogleCalendarConnection } from './oauth'
import { isMemryUserSignedIn } from '../../sync/auth-state'
import { createGoogleCalendarClient } from './client'
import {
mapCalendarEventToGoogleInput,
Expand Down Expand Up @@ -339,9 +340,8 @@ export async function syncLocalSourceToGoogleCalendar(
>
} = {}
): Promise<typeof calendarBindings.$inferSelect | null> {
if (!(await hasGoogleCalendarLocalAuth())) {
return null
}
if (!(await isMemryUserSignedIn())) return null
if (!(await hasGoogleCalendarConnection(db))) return null

if (shouldSourceSyncToGoogleCalendar(db, target)) {
return await pushSourceToGoogleCalendar(db, target, deps)
Expand Down Expand Up @@ -568,9 +568,8 @@ export async function syncGoogleCalendarNow(
deps: { client?: GoogleCalendarClient } = {}
): Promise<void> {
if (syncInFlight) return
if (!(await hasGoogleCalendarLocalAuth())) {
return
}
if (!(await isMemryUserSignedIn())) return
if (!(await hasGoogleCalendarConnection(db))) return

syncInFlight = true
try {
Expand All @@ -591,8 +590,10 @@ export async function syncGoogleCalendarNow(
}
}

export function startGoogleCalendarSyncRunner(): void {
export async function startGoogleCalendarSyncRunner(): Promise<void> {
if (syncInterval) return
if (!(await isMemryUserSignedIn())) return
if (!(await hasGoogleCalendarConnection(requireDatabase()))) return

void syncGoogleCalendarNow().catch((error) => {
log.warn('initial Google Calendar sync failed', error)
Expand Down
22 changes: 11 additions & 11 deletions apps/desktop/src/main/crypto/__fixtures__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ drift the moment a vector file is edited.

## Files

| File | Algorithm | Source |
| --- | --- | --- |
| `xchacha20-rfc8439.ts` | XChaCha20-Poly1305 (IETF) | [draft-irtf-cfrg-xchacha-03 §A.3.1](https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha-03#appendix-A.3.1) |
| `ed25519-rfc8032.ts` | Ed25519 (pure) | [RFC 8032 §7.1](https://datatracker.ietf.org/doc/html/rfc8032#section-7.1) |
| `argon2id-rfc9106.ts` | Argon2id | [RFC 9106 §5.3](https://datatracker.ietf.org/doc/html/rfc9106#section-5.3) |
| `load-vectors.ts` | typed loader + shape assertions | — |
| File | Algorithm | Source |
| ---------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `xchacha20-rfc8439.ts` | XChaCha20-Poly1305 (IETF) | [draft-irtf-cfrg-xchacha-03 §A.3.1](https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha-03#appendix-A.3.1) |
| `ed25519-rfc8032.ts` | Ed25519 (pure) | [RFC 8032 §7.1](https://datatracker.ietf.org/doc/html/rfc8032#section-7.1) |
| `argon2id-rfc9106.ts` | Argon2id | [RFC 9106 §5.3](https://datatracker.ietf.org/doc/html/rfc9106#section-5.3) |
| `load-vectors.ts` | typed loader + shape assertions | — |

All vector files were captured on **2026-04-16**. The retrieval date is also
recorded in each file header and on each exported vector constant.
Expand Down Expand Up @@ -53,11 +53,11 @@ pnpm exec tsx -e '

Substitute the vector import + concatenated bytes as appropriate:

| Vector file | Bytes to hash |
| --- | --- |
| Vector file | Bytes to hash |
| ---------------------- | ------------------ |
| `xchacha20-rfc8439.ts` | `ciphertext ‖ tag` |
| `ed25519-rfc8032.ts` | `signature` |
| `argon2id-rfc9106.ts` | `tag` |
| `ed25519-rfc8032.ts` | `signature` |
| `argon2id-rfc9106.ts` | `tag` |

Once a sentinel is pinned, edits that change the literal will produce a hash
mismatch the first time a consuming test re-runs the recipe, surfacing the
Expand All @@ -68,7 +68,7 @@ tamper.
If the upstream vector ever needs to be re-verified:

1. Open the source URL listed in the file header.
2. Copy each hex blob *exactly* — keep groupings reproducible (we use
2. Copy each hex blob _exactly_ — keep groupings reproducible (we use
32-hex-char chunks so diffs stay local).
3. Update the `retrievedAt` field on the vector constant **and** the
per-file header comment to match the day of re-fetch (ISO `YYYY-MM-DD`).
Expand Down
7 changes: 2 additions & 5 deletions apps/desktop/src/main/crypto/__fixtures__/argon2id-rfc9106.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ const SECRET_HEX = '03'.repeat(8)
const ASSOCIATED_DATA_HEX = '04'.repeat(12)

// Expected output tag (32 bytes) per RFC 9106 §5.3 final hash.
const TAG_HEX =
'0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659'
const TAG_HEX = '0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659'

// Tamper sentinel: SHA-256 of `hexToBytes(TAG_HEX)`. Pin via ./README.md →
// "Tamper sentinels" recipe on first consumer test run.
Expand All @@ -57,6 +56,4 @@ export const ARGON2ID_RFC9106_VECTOR: Argon2idVector = assertArgon2idVector({
tag: hexToBytes(TAG_HEX)
})

export const ARGON2ID_RFC9106_VECTORS: readonly Argon2idVector[] = [
ARGON2ID_RFC9106_VECTOR
]
export const ARGON2ID_RFC9106_VECTORS: readonly Argon2idVector[] = [ARGON2ID_RFC9106_VECTOR]
12 changes: 4 additions & 8 deletions apps/desktop/src/main/crypto/__fixtures__/ed25519-rfc8032.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,9 @@ import { assertEd25519Vector, hexToBytes, type Ed25519Vector } from './load-vect
// TEST 1 — empty message
// ---------------------------------------------------------------------------

const TEST1_SEED_HEX =
'9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60'
const TEST1_SEED_HEX = '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60'

const TEST1_PUBLIC_KEY_HEX =
'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a'
const TEST1_PUBLIC_KEY_HEX = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a'

const TEST1_MESSAGE_HEX = ''

Expand All @@ -45,11 +43,9 @@ export const ED25519_RFC8032_TEST_1: Ed25519Vector = assertEd25519Vector({
// TEST 2 — single-byte message 0x72
// ---------------------------------------------------------------------------

const TEST2_SEED_HEX =
'4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb'
const TEST2_SEED_HEX = '4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb'

const TEST2_PUBLIC_KEY_HEX =
'3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c'
const TEST2_PUBLIC_KEY_HEX = '3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c'

const TEST2_MESSAGE_HEX = '72'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ const fromHex = (hex: string): Uint8Array => {
}

export const IETF_XCHACHA20_POLY1305_VECTOR = {
key: fromHex(
'808182838485868788898a8b8c8d8e8f' + '909192939495969798999a9b9c9d9e9f'
),
key: fromHex('808182838485868788898a8b8c8d8e8f' + '909192939495969798999a9b9c9d9e9f'),
nonce: fromHex('404142434445464748494a4b4c4d4e4f5051525354555657'),
aad: fromHex('50515253c0c1c2c3c4c5c6c7'),
plaintext: fromHex(
Expand All @@ -38,7 +36,8 @@ export const IETF_XCHACHA20_POLY1305_VECTOR = {
'bd6d179d3e83d43b9576579493c0e939572a1700252bfaccbed2902c21396cbb' +
'731c7f1b0b4aa6440bf3a82f4eda7e39ae64c6708c54c216cb96b72e1213b452' +
'2f8c9ba40db5d945b11b69b982c1bb9e3f3fac2bc369488f76b2383565d3fff9' +
'21f9664c97637da9768812f615c68b13b52e' + 'c0875924c1c7987947deafd8780acf49'
'21f9664c97637da9768812f615c68b13b52e' +
'c0875924c1c7987947deafd8780acf49'
)
} as const

Expand Down
33 changes: 16 additions & 17 deletions apps/desktop/src/main/crypto/encryption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,11 @@ describe('encryption', () => {
throw new Error('forced decrypt failure')
})

expectCryptoError(() => decrypt(ciphertext, nonce, key), 'DECRYPTION_FAILED', /forced decrypt failure/)
expectCryptoError(
() => decrypt(ciphertext, nonce, key),
'DECRYPTION_FAILED',
/forced decrypt failure/
)

decryptSpy.mockRestore()
})
Expand All @@ -209,15 +213,19 @@ describe('encryption', () => {
throw 'forced string failure'
})

expectCryptoError(() => decrypt(ciphertext, nonce, key), 'DECRYPTION_FAILED', /Decryption failed/)
expectCryptoError(
() => decrypt(ciphertext, nonce, key),
'DECRYPTION_FAILED',
/Decryption failed/
)

decryptSpy.mockRestore()
})

it('throws when sodium returns a nonce with the wrong length', () => {
const randombytesSpy = vi.spyOn(sodium, 'randombytes_buf').mockReturnValueOnce(
new Uint8Array(XCHACHA20_PARAMS.NONCE_LENGTH - 1)
)
const randombytesSpy = vi
.spyOn(sodium, 'randombytes_buf')
.mockReturnValueOnce(new Uint8Array(XCHACHA20_PARAMS.NONCE_LENGTH - 1))

expect(() => generateNonce()).toThrow(
`Nonce length mismatch: expected ${XCHACHA20_PARAMS.NONCE_LENGTH}, got ${XCHACHA20_PARAMS.NONCE_LENGTH - 1}`
Expand Down Expand Up @@ -257,18 +265,9 @@ describe('encryption', () => {
const { ciphertext, nonce } = encrypt(plaintext, validKey)
const shortNonce = new Uint8Array(XCHACHA20_PARAMS.NONCE_LENGTH - 1)

expectCryptoError(
() => encrypt(plaintext, shortKey),
'INVALID_KEY_LENGTH'
)
expectCryptoError(
() => decrypt(ciphertext, nonce, shortKey),
'INVALID_KEY_LENGTH'
)
expectCryptoError(
() => decrypt(ciphertext, shortNonce, validKey),
'INVALID_NONCE_LENGTH'
)
expectCryptoError(() => encrypt(plaintext, shortKey), 'INVALID_KEY_LENGTH')
expectCryptoError(() => decrypt(ciphertext, nonce, shortKey), 'INVALID_KEY_LENGTH')
expectCryptoError(() => decrypt(ciphertext, shortNonce, validKey), 'INVALID_NONCE_LENGTH')
})

it('matches the IETF XChaCha20-Poly1305 golden vector for decrypt', () => {
Expand Down
4 changes: 1 addition & 3 deletions apps/desktop/src/main/crypto/keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,7 @@ describe('generateDeviceSigningKeyPair', () => {
expect(keyPair.deviceId).toHaveLength(32)

// #then deviceId is BLAKE2b-128 of the public key, hex-encoded
const expectedDeviceId = sodium.to_hex(
sodium.crypto_generichash(16, keyPair.publicKey, null)
)
const expectedDeviceId = sodium.to_hex(sodium.crypto_generichash(16, keyPair.publicKey, null))
expect(keyPair.deviceId).toBe(expectedDeviceId)
})

Expand Down
Loading
Loading