// iCloud calendar access over CalDAV, using an Apple ID + app-specific password (no app registration). // Create the password at appleid.apple.com -> Sign-In and Security -> App-Specific Passwords. // Credentials are encrypted at rest with a key derived from the server's secret. Only *.icloud.com is ever contacted. const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const auth = require('./auth'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const FILE = path.join(DATA_DIR, 'icloud.json'); const TEST = process.env.CALDAV_TEST === '1'; // lets the tests talk to a local stand-in server; never set in production const BASE = TEST ? process.env.CALDAV_BASE : 'https://caldav.icloud.com'; // ---- Encrypted credential store const key = () => crypto.scryptSync(auth.secret, 'nehub-icloud', 32); const seal = obj => { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', key(), iv); const d = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); }; const unseal = b64 => { const b = Buffer.from(b64, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12)); d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); }; function load() { try { return unseal(JSON.parse(fs.readFileSync(FILE, 'utf8')).data); } catch { return null; } } function save(o) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify({ data: seal(o) }), { mode: 0o600 }); } function wipe() { try { fs.unlinkSync(FILE); } catch {} } // ---- HTTP + XML const allowed = u => TEST ? ['localhost', '127.0.0.1'].includes(u.hostname) : u.protocol === 'https:' && (u.hostname === 'icloud.com' || u.hostname.endsWith('.icloud.com')); async function dav(method, url, { user, pass, depth, body, headers = {} } = {}) { let u = new URL(url); for (let hop = 0; hop < 4; hop++) { if (!allowed(u)) throw new Error('Refusing to contact ' + u.hostname); const r = await fetch(u, { method, redirect: 'manual', signal: AbortSignal.timeout(20000), body, headers: { Authorization: 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'), ...(depth !== undefined ? { Depth: String(depth) } : {}), ...(body ? { 'Content-Type': 'application/xml; charset=utf-8' } : {}), ...headers } }); if (r.status >= 300 && r.status < 400 && r.headers.get('location')) { u = new URL(r.headers.get('location'), u); continue; } return { status: r.status, text: await r.text(), headers: r.headers, url: u.toString() }; } throw new Error('Too many redirects'); } const decode = s => s.replace(//g, (_, x) => x.replace(/&/g, '&').replace(//g, '>')) .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, '\r').replace(/ /g, '\n').replace(/&/g, '&'); const tag = (xml, name) => { const m = new RegExp(`<(?:\\w+:)?${name}(?:\\s[^>]*)?>([\\s\\S]*?)`, 'i').exec(xml); return m ? m[1] : null; }; const responses = xml => (xml.match(/<(?:\w+:)?response(?:\s[^>]*)?>[\s\S]*?<\/(?:\w+:)?response>/gi) || []); const hrefOf = block => { const h = tag(block, 'href'); return h ? decode(h).trim() : null; }; const abs = (href, base) => new URL(href, base).toString(); const PROPFIND_PRINCIPAL = ''; const PROPFIND_HOME = ''; const PROPFIND_CALS = ''; const stamp = ms => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z'); const REPORT = (from, to) => ` `; const calId = href => 'icloud:' + crypto.createHash('sha1').update(href).digest('hex').slice(0, 10); /** Sign in, find the calendars, and remember everything (encrypted). Throws a readable message on failure. */ async function connect(appleId, password) { const o = { user: String(appleId || '').trim(), pass: String(password || '').replace(/\s/g, '') }; if (!o.user || !o.pass) throw new Error('Enter your Apple ID and an app-specific password'); const a = await dav('PROPFIND', BASE + '/', { ...o, depth: 0, body: PROPFIND_PRINCIPAL }); if (a.status === 401 || a.status === 403) throw new Error('Apple rejected that Apple ID or app-specific password. It must be an app-specific password, not your normal one.'); if (a.status >= 400) throw new Error('iCloud answered with an error (' + a.status + ')'); const principalHref = hrefOf(tag(a.text, 'current-user-principal') || ''); if (!principalHref) throw new Error('iCloud did not return an account (is calendar turned on for this Apple ID?)'); const b = await dav('PROPFIND', abs(principalHref, a.url), { ...o, depth: 0, body: PROPFIND_HOME }); const homeHref = hrefOf(tag(b.text, 'calendar-home-set') || ''); if (!homeHref) throw new Error('Could not find the calendar home for this account'); const home = abs(homeHref, b.url); const c = await dav('PROPFIND', home, { ...o, depth: 1, body: PROPFIND_CALS }); const calendars = []; for (const r of responses(c.text)) { const rt = tag(r, 'resourcetype') || ''; if (!/<(?:\w+:)?calendar\s*\/?>/i.test(rt)) continue; const comps = tag(r, 'supported-calendar-component-set'); if (comps && !/name="VEVENT"/i.test(comps)) continue; // reminders lists etc. const href = abs(hrefOf(r), c.url); const color = (tag(r, 'calendar-color') || '').trim().slice(0, 7); calendars.push({ id: calId(href), href, name: decode(tag(r, 'displayname') || 'Calendar').trim() || 'Calendar', color: /^#[0-9a-f]{6}$/i.test(color) ? color : '#d9453a', enabled: true }); } if (!calendars.length) throw new Error('No calendars found on this account'); save({ user: o.user, pass: o.pass, calendars }); return status(); } const status = () => { const s = load(); return s ? { connected: true, appleId: s.user, calendars: s.calendars.map(({ id, name, color, enabled }) => ({ id, name, color, enabled })) } : { connected: false }; }; function toggle(id, enabled) { const s = load(); if (!s) throw new Error('Not connected'); const c = s.calendars.find(x => x.id === id); if (!c) throw new Error('Calendar not found'); c.enabled = !!enabled; save(s); return status(); } const enabled = () => { const s = load(); return s ? s.calendars.filter(c => c.enabled).map(({ id, name, color }) => ({ id, name, color })) : []; }; const inside = (c, href) => { if (!String(href || '').startsWith(c.href.replace(/\/?$/, '/'))) throw new Error('That event is not in this calendar'); }; const cred = id => { const s = load(); if (!s) throw new Error('iCloud is not connected'); const c = s.calendars.find(x => x.id === id); if (!c) throw new Error('Calendar not found'); return { s, c, o: { user: s.user, pass: s.pass } }; }; /** Raw calendar entries overlapping the window: [{ calId, name, color, href, etag, ics }] */ async function listRaw(from, to) { const s = load(); if (!s) return []; const out = []; await Promise.all(s.calendars.filter(c => c.enabled).map(async c => { const r = await dav('REPORT', c.href, { user: s.user, pass: s.pass, depth: 1, body: REPORT(from - 864e5, to + 864e5) }); if (r.status === 401) throw new Error('iCloud sign-in no longer works. Reconnect with a new app-specific password.'); if (r.status >= 400) throw new Error(`${c.name}: iCloud answered ${r.status}`); for (const blk of responses(r.text)) { const data = tag(blk, 'calendar-data'); if (!data) continue; out.push({ calId: c.id, name: c.name, color: c.color, href: abs(hrefOf(blk), r.url), etag: decode(tag(blk, 'getetag') || '').trim(), ics: decode(data) }); } })); return out; } async function getResource(id, href) { const { c, o } = cred(id); inside(c, href); const r = await dav('GET', href, o); if (r.status === 404) throw new Error('That event no longer exists'); if (r.status >= 400) throw new Error('iCloud answered ' + r.status); return { ics: r.text, etag: r.headers.get('etag') || '' }; } async function putEvent(id, uid, icsText, { href, etag } = {}) { const { c, o } = cred(id); if (href) inside(c, href); const url = href || new URL(encodeURIComponent(String(uid).split('@')[0]) + '.ics', c.href.replace(/\/?$/, '/')).toString(); const r = await dav('PUT', url, { ...o, body: icsText, headers: { 'Content-Type': 'text/calendar; charset=utf-8', ...(href ? (etag ? { 'If-Match': etag } : {}) : { 'If-None-Match': '*' }) } }); if (r.status === 412) throw new Error('This event was changed somewhere else. Reload the calendar and try again.'); if (r.status === 401) throw new Error('iCloud sign-in no longer works. Reconnect with a new app-specific password.'); if (r.status >= 400) throw new Error('iCloud refused the change (' + r.status + ')'); return { href: url, etag: r.headers.get('etag') || '' }; } async function deleteEvent(id, href, etag) { const { c, o } = cred(id); inside(c, href); const r = await dav('DELETE', href, { ...o, headers: etag ? { 'If-Match': etag } : {} }); if (r.status === 412) throw new Error('This event was changed somewhere else. Reload the calendar and try again.'); if (r.status >= 400 && r.status !== 404) throw new Error('iCloud refused the delete (' + r.status + ')'); } module.exports = { connect, status, toggle, enabled, listRaw, getResource, putEvent, deleteEvent, wipe, isCal: id => /^icloud:[0-9a-f]{10}$/.test(id || '') };