wicker-study-mcp 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/authorize.mjs +132 -0
- package/config.mjs +109 -0
- package/package.json +35 -0
- package/scripts/macos-keychain.swift +79 -0
- package/server.mjs +621 -0
- package/vendor/canvas-course-export.mjs +60 -0
- package/vendor/canvas-course-import.mjs +685 -0
- package/vendor/local-canvas-prompts.mjs +214 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# wicker-study-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [Wicker Study](https://study.wicker.life) — a private, source-grounded academic
|
|
4
|
+
workspace. It gives an agent read access to course material and a student's academic record, the
|
|
5
|
+
ability to study on their behalf, a Canvas course importer that never sees the Canvas token, and —
|
|
6
|
+
with an admin key — the whole editorial content workflow.
|
|
7
|
+
|
|
8
|
+
Runs from anywhere. Nothing here needs a checkout of the application.
|
|
9
|
+
|
|
10
|
+
## Use it
|
|
11
|
+
|
|
12
|
+
```jsonc
|
|
13
|
+
// Claude Desktop / Claude Code / Cursor MCP config
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"wicker-study": {
|
|
17
|
+
"command": "npx",
|
|
18
|
+
"args": ["-y", "wicker-study-mcp"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then ask the agent to connect. It calls `wicker_status`, finds no key, calls `wicker_authorize`,
|
|
25
|
+
and gives you a URL to approve in your browser. The key comes straight back to your machine over
|
|
26
|
+
loopback and is saved for every future session.
|
|
27
|
+
|
|
28
|
+
No environment variable is required. Two are honoured when set:
|
|
29
|
+
|
|
30
|
+
| Variable | Meaning |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `WICKER_STUDY_URL` | Which server to use. Default `https://study.wicker.life`. Use `http://localhost:4177` for a local development server; plain http is refused for anything else. |
|
|
33
|
+
| `WICKER_STUDY_API_KEY` | Use this key instead of the saved one. Always wins, so a one-off run and CI never pick up a developer's saved key. |
|
|
34
|
+
|
|
35
|
+
## Where the key lives
|
|
36
|
+
|
|
37
|
+
`~/.config/wicker-study/config.json`, mode `0600` in a `0700` directory, keyed by server URL so a
|
|
38
|
+
local and a production key never overwrite each other. `wicker_sign_out` removes it. Revoke the key
|
|
39
|
+
itself under **Account → API access** in the web app.
|
|
40
|
+
|
|
41
|
+
The key is never printed, never a tool argument, and never sent to a host other than the one it was
|
|
42
|
+
minted for. Wicker Study will only deliver it to a loopback address, so a link that asks for
|
|
43
|
+
anything else is refused by the approval page.
|
|
44
|
+
|
|
45
|
+
## Canvas
|
|
46
|
+
|
|
47
|
+
Canvas material is reached through the account's own encrypted connection, not through the agent.
|
|
48
|
+
Call `canvas_connect` first: it reports whether the account has a Canvas connection and, if not,
|
|
49
|
+
returns the page where the student pastes their Canvas Personal Access Token — in their browser.
|
|
50
|
+
|
|
51
|
+
**Never ask a user for a Canvas token, password, MFA code, cookie, or session export in chat.** The
|
|
52
|
+
agent receives proxied course data and nothing else.
|
|
53
|
+
|
|
54
|
+
## Tools
|
|
55
|
+
|
|
56
|
+
`wicker_status`, `wicker_authorize`, `wicker_sign_out`, and `canvas_connect` work without a key —
|
|
57
|
+
they are how a key is obtained. Everything else needs one.
|
|
58
|
+
|
|
59
|
+
- **Reading** — `list_courses`, `get_course`, `get_chapter`, `get_course_outline`, `search_course`,
|
|
60
|
+
`list_materials`, `list_questions`, `get_practice_queue`, `get_progress`, `list_flashcards`,
|
|
61
|
+
`list_due_cards`, `list_mistakes`, `list_mock_sessions`, `get_mock_session`, `get_academic_plan`,
|
|
62
|
+
`list_known_programmes`, `get_calendar`, `get_activity`, `get_account_summary`, `whoami`
|
|
63
|
+
- **Studying** — `submit_answer`, `set_mastery`, `review_card`, `add_to_deck`, `create_flashcard`,
|
|
64
|
+
`review_flashcard`, `resolve_mistake`, `record_chapter_read`, `save_academic_plan`,
|
|
65
|
+
`set_course_visibility`, `join_programme`
|
|
66
|
+
- **Documents and calendars** — `analyze_documents`, `apply_changes`, `preview_calendar`,
|
|
67
|
+
`save_calendar_link`, `sync_calendar_link`, `remove_calendar_link`
|
|
68
|
+
- **Canvas** — `canvas_connect`, `canvas_list_remote_courses`, `canvas_list_remote_course_modules`,
|
|
69
|
+
`canvas_import_remote_course`, `canvas_import_remote_course_set`
|
|
70
|
+
- **Editorial (admin key)** — the `admin_*` family: course editions, source folders, rights review,
|
|
71
|
+
extraction, mapping, generation, artifact review, and publication.
|
|
72
|
+
|
|
73
|
+
`GET /api/agent/manifest` (also exposed as the `wicker-study://manifest` resource) is the
|
|
74
|
+
authoritative list of endpoints and scopes.
|
|
75
|
+
|
|
76
|
+
## Licence
|
|
77
|
+
|
|
78
|
+
MIT.
|
package/authorize.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Getting a key without putting one in a chat transcript.
|
|
2
|
+
//
|
|
3
|
+
// The agent opens a listener bound to loopback, sends the user to /connect with
|
|
4
|
+
// a verifier challenge and that listener's address, and waits. The browser
|
|
5
|
+
// approves; Wicker Study redirects back to loopback with a single-use code; the
|
|
6
|
+
// agent exchanges code + verifier for a key and stores it globally.
|
|
7
|
+
//
|
|
8
|
+
// The key therefore travels browser → loopback → disk. It is never printed,
|
|
9
|
+
// never passed as a tool argument, and never leaves the machine that asked for
|
|
10
|
+
// it — Wicker Study refuses any callback that is not loopback.
|
|
11
|
+
|
|
12
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
13
|
+
import { createServer } from 'node:http'
|
|
14
|
+
import { saveApiKey } from './config.mjs'
|
|
15
|
+
|
|
16
|
+
export const AUTHORIZE_TIMEOUT_MS = 5 * 60_000
|
|
17
|
+
|
|
18
|
+
function base64url(bytes) { return bytes.toString('base64url') }
|
|
19
|
+
|
|
20
|
+
function sameSecret(left, right) {
|
|
21
|
+
const a = Buffer.from(String(left || ''))
|
|
22
|
+
const b = Buffer.from(String(right || ''))
|
|
23
|
+
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function page(title, body) {
|
|
27
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title><style>
|
|
28
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f7f7f4;color:#20263a;
|
|
29
|
+
font:400 15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
30
|
+
main{max-width:420px;padding:32px;background:#fff;border:1px solid #dfe2ea;border-radius:12px;text-align:center}
|
|
31
|
+
h1{margin:0 0 8px;font-size:19px;letter-spacing:-.02em}p{margin:0;color:#59627b;font-size:13.5px}
|
|
32
|
+
</style></head><body><main><h1>${title}</h1><p>${body}</p></main></body></html>`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Bound to 127.0.0.1 explicitly so nothing on the network can reach it. The
|
|
36
|
+
// caller needs the address straight away and the code much later, so the two
|
|
37
|
+
// are handed back as separate promises.
|
|
38
|
+
export function startCallbackListener({ timeoutMs = AUTHORIZE_TIMEOUT_MS } = {}) {
|
|
39
|
+
const state = base64url(randomBytes(24))
|
|
40
|
+
let resolveCode
|
|
41
|
+
let rejectCode
|
|
42
|
+
const code = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject })
|
|
43
|
+
let settled = false
|
|
44
|
+
|
|
45
|
+
const server = createServer((req, res) => {
|
|
46
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
47
|
+
if (url.pathname !== '/callback') { res.writeHead(404).end(); return }
|
|
48
|
+
if (!sameSecret(url.searchParams.get('state') || '', state)) {
|
|
49
|
+
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
|
|
50
|
+
res.end(page('That did not match', 'This response did not come from the authorization this agent started. Nothing has been saved.'))
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
const failure = url.searchParams.get('error')
|
|
54
|
+
const value = url.searchParams.get('code')
|
|
55
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
56
|
+
res.end(failure || !value
|
|
57
|
+
? page('Authorization cancelled', 'Nothing was granted. You can close this tab and try again from your agent.')
|
|
58
|
+
: page('Wicker Study is connected', 'You can close this tab and go back to your agent.'))
|
|
59
|
+
settle(failure ? new Error('The authorization was cancelled in the browser.') : null, value)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
function settle(error, value) {
|
|
63
|
+
if (settled) return
|
|
64
|
+
settled = true
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
setTimeout(() => server.close(), 250).unref?.()
|
|
67
|
+
if (error) rejectCode(error); else resolveCode(value)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const timer = setTimeout(() => settle(new Error(`No response within ${Math.round(timeoutMs / 60_000)} minutes. Start the authorization again.`)), timeoutMs)
|
|
71
|
+
timer.unref?.()
|
|
72
|
+
server.once('error', (error) => settle(error))
|
|
73
|
+
|
|
74
|
+
const address = new Promise((resolve, reject) => {
|
|
75
|
+
server.once('error', reject)
|
|
76
|
+
server.listen(0, '127.0.0.1', () => {
|
|
77
|
+
const { port } = server.address()
|
|
78
|
+
resolve({ port, redirectUri: `http://127.0.0.1:${port}/callback`, state })
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return { address, code, state, cancel: () => settle(new Error('The authorization was cancelled.')) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function authorizationUrl(serverUrl, { name, scopes, challenge, state, redirectUri }) {
|
|
86
|
+
const url = new URL('/connect', serverUrl)
|
|
87
|
+
url.searchParams.set('name', name)
|
|
88
|
+
url.searchParams.set('scopes', scopes.join(','))
|
|
89
|
+
url.searchParams.set('challenge', challenge)
|
|
90
|
+
url.searchParams.set('state', state)
|
|
91
|
+
url.searchParams.set('redirect_uri', redirectUri)
|
|
92
|
+
return url.toString()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function makeVerifier() {
|
|
96
|
+
const verifier = base64url(randomBytes(32))
|
|
97
|
+
return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function exchange(serverUrl, { code, verifier }) {
|
|
101
|
+
const response = await fetch(new URL('/api/agent/authorize/exchange', serverUrl), {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
104
|
+
body: JSON.stringify({ code, verifier })
|
|
105
|
+
})
|
|
106
|
+
const body = await response.json().catch(() => null)
|
|
107
|
+
if (!response.ok) throw new Error(body?.error || `The authorization could not be completed (HTTP ${response.status}).`)
|
|
108
|
+
if (!body?.apiKey) throw new Error('Wicker Study returned no API key.')
|
|
109
|
+
return body
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The whole flow. Returns the loopback URL to show the user immediately, and a
|
|
113
|
+
// promise that settles when they have finished in the browser.
|
|
114
|
+
export function beginAuthorization(serverUrl, { name = 'Agent (MCP)', scopes = ['read', 'write'], timeoutMs = AUTHORIZE_TIMEOUT_MS } = {}) {
|
|
115
|
+
const { verifier, challenge } = makeVerifier()
|
|
116
|
+
const listener = startCallbackListener({ timeoutMs })
|
|
117
|
+
const ready = listener.address.then((address) => ({
|
|
118
|
+
url: authorizationUrl(serverUrl, { name, scopes, challenge, state: address.state, redirectUri: address.redirectUri }),
|
|
119
|
+
redirectUri: address.redirectUri
|
|
120
|
+
}))
|
|
121
|
+
const completed = (async () => {
|
|
122
|
+
await ready
|
|
123
|
+
const granted = await exchange(serverUrl, { code: await listener.code, verifier })
|
|
124
|
+
const saved = await saveApiKey(serverUrl, granted.apiKey, granted)
|
|
125
|
+
return { ...saved, name: granted.name, scopes: granted.scopes, expiresAt: granted.expiresAt }
|
|
126
|
+
})()
|
|
127
|
+
// The caller may only await this much later, or never (the user walked away).
|
|
128
|
+
// Keep an unobserved rejection from taking the process down; the rejection is
|
|
129
|
+
// still delivered to whoever does await it.
|
|
130
|
+
completed.catch(() => {})
|
|
131
|
+
return { ready, completed, cancel: listener.cancel }
|
|
132
|
+
}
|
package/config.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Where an authorised key lives once the agent has one.
|
|
2
|
+
//
|
|
3
|
+
// Keys are stored per server URL, so a developer pointing at localhost and the
|
|
4
|
+
// same person pointing at study.wicker.life do not overwrite each other, and so
|
|
5
|
+
// a key is never sent to a host it was not minted for. The file is written
|
|
6
|
+
// 0600 and its directory 0700; the key never appears in a shell history, a
|
|
7
|
+
// project directory, or a chat transcript.
|
|
8
|
+
|
|
9
|
+
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
export function configDirectory(env = process.env) {
|
|
14
|
+
const explicit = String(env.WICKER_STUDY_CONFIG_DIR || '').trim()
|
|
15
|
+
if (explicit) return explicit
|
|
16
|
+
const xdg = String(env.XDG_CONFIG_HOME || '').trim()
|
|
17
|
+
return join(xdg || join(homedir(), '.config'), 'wicker-study')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function configPath(env = process.env) {
|
|
21
|
+
return join(configDirectory(env), 'config.json')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normaliseServerUrl(value) {
|
|
25
|
+
const raw = String(value || '').trim()
|
|
26
|
+
if (!raw) throw new Error('A Wicker Study URL is required.')
|
|
27
|
+
let url
|
|
28
|
+
try { url = new URL(raw) } catch { throw new Error(`"${raw}" is not a valid Wicker Study URL.`) }
|
|
29
|
+
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('A Wicker Study URL must be http or https.')
|
|
30
|
+
// http is only sensible against a local development server; anything else
|
|
31
|
+
// would put a bearer key on the wire in clear text.
|
|
32
|
+
if (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
|
|
33
|
+
throw new Error(`Refusing to use http for ${url.hostname}. Use https, or point at a local development server.`)
|
|
34
|
+
}
|
|
35
|
+
return url.origin
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readConfig(env = process.env) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(await readFile(configPath(env), 'utf8'))
|
|
41
|
+
return parsed && typeof parsed === 'object' && parsed.servers && typeof parsed.servers === 'object' ? parsed : { servers: {} }
|
|
42
|
+
} catch {
|
|
43
|
+
return { servers: {} }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function writeConfig(config, env = process.env) {
|
|
48
|
+
const path = configPath(env)
|
|
49
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
50
|
+
await chmod(dirname(path), 0o700).catch(() => {})
|
|
51
|
+
// Write-then-rename so an interrupted save cannot leave a truncated file, and
|
|
52
|
+
// set the mode before the content is in place at its final name.
|
|
53
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
54
|
+
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
|
|
55
|
+
await chmod(temporary, 0o600).catch(() => {})
|
|
56
|
+
await rename(temporary, path)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function savedApiKey(serverUrl, env = process.env) {
|
|
60
|
+
const config = await readConfig(env)
|
|
61
|
+
const entry = config.servers[normaliseServerUrl(serverUrl)]
|
|
62
|
+
return entry && typeof entry.apiKey === 'string' && entry.apiKey ? entry.apiKey : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function saveApiKey(serverUrl, apiKey, details = {}, env = process.env) {
|
|
66
|
+
const origin = normaliseServerUrl(serverUrl)
|
|
67
|
+
const key = String(apiKey || '').trim()
|
|
68
|
+
if (!key.startsWith('wsk_')) throw new Error('That does not look like a Wicker Study API key (they start with wsk_).')
|
|
69
|
+
const config = await readConfig(env)
|
|
70
|
+
config.servers[origin] = {
|
|
71
|
+
apiKey: key,
|
|
72
|
+
name: details.name || null,
|
|
73
|
+
scopes: Array.isArray(details.scopes) ? details.scopes : null,
|
|
74
|
+
expiresAt: details.expiresAt || null,
|
|
75
|
+
savedAt: new Date().toISOString()
|
|
76
|
+
}
|
|
77
|
+
await writeConfig(config, env)
|
|
78
|
+
return { server: origin, path: configPath(env) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function forgetApiKey(serverUrl, env = process.env) {
|
|
82
|
+
const origin = normaliseServerUrl(serverUrl)
|
|
83
|
+
const config = await readConfig(env)
|
|
84
|
+
if (!config.servers[origin]) return false
|
|
85
|
+
delete config.servers[origin]
|
|
86
|
+
await writeConfig(config, env)
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function listSavedServers(env = process.env) {
|
|
91
|
+
const config = await readConfig(env)
|
|
92
|
+
// Never return the key itself — only that one exists and what it can do.
|
|
93
|
+
return Object.entries(config.servers).map(([server, entry]) => ({
|
|
94
|
+
server,
|
|
95
|
+
name: entry?.name || null,
|
|
96
|
+
scopes: entry?.scopes || null,
|
|
97
|
+
savedAt: entry?.savedAt || null,
|
|
98
|
+
expiresAt: entry?.expiresAt || null
|
|
99
|
+
}))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The environment always wins, so a one-off `WICKER_STUDY_API_KEY=… npx …`
|
|
103
|
+
// still works and CI never picks up a developer's saved key by accident.
|
|
104
|
+
export async function resolveApiKey(serverUrl, env = process.env) {
|
|
105
|
+
const fromEnv = String(env.WICKER_STUDY_API_KEY || '').trim()
|
|
106
|
+
if (fromEnv) return { apiKey: fromEnv, source: 'environment' }
|
|
107
|
+
const stored = await savedApiKey(serverUrl, env)
|
|
108
|
+
return stored ? { apiKey: stored, source: configPath(env) } : { apiKey: null, source: null }
|
|
109
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wicker-study-mcp",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "MCP server for Wicker Study: read course material and a student's academic record, study on their behalf, collect a private Canvas course snapshot, and — with an admin key — run the editorial workflow.",
|
|
5
|
+
"keywords": ["mcp", "model-context-protocol", "wicker-study", "canvas-lms", "study", "education"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "David Wicker",
|
|
8
|
+
"homepage": "https://study.wicker.life",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/davidwickerhf/exam-study-platform.git",
|
|
12
|
+
"directory": "mcp"
|
|
13
|
+
},
|
|
14
|
+
"bugs": "https://github.com/davidwickerhf/exam-study-platform/issues",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"wicker-study-mcp": "./server.mjs"
|
|
18
|
+
},
|
|
19
|
+
"main": "./server.mjs",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20.11"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"server.mjs",
|
|
25
|
+
"config.mjs",
|
|
26
|
+
"authorize.mjs",
|
|
27
|
+
"vendor/",
|
|
28
|
+
"scripts/",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
33
|
+
"zod": "^3.25.76"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
|
|
4
|
+
struct KeychainRequest: Decodable {
|
|
5
|
+
let operation: String
|
|
6
|
+
let service: String
|
|
7
|
+
let account: String
|
|
8
|
+
let value: String?
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
struct KeychainResponse: Encodable {
|
|
12
|
+
let found: Bool
|
|
13
|
+
let value: String?
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func write(_ response: KeychainResponse) {
|
|
17
|
+
guard let data = try? JSONEncoder().encode(response) else { exit(1) }
|
|
18
|
+
FileHandle.standardOutput.write(data)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func fail(_ status: OSStatus) -> Never {
|
|
22
|
+
let text = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)"
|
|
23
|
+
FileHandle.standardError.write(Data(text.utf8))
|
|
24
|
+
exit(1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let input = FileHandle.standardInput.readDataToEndOfFile()
|
|
28
|
+
guard let request = try? JSONDecoder().decode(KeychainRequest.self, from: input),
|
|
29
|
+
!request.service.isEmpty,
|
|
30
|
+
!request.account.isEmpty else {
|
|
31
|
+
FileHandle.standardError.write(Data("Invalid Keychain request".utf8))
|
|
32
|
+
exit(1)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func baseQuery() -> [String: Any] {
|
|
36
|
+
[
|
|
37
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
38
|
+
kSecAttrService as String: request.service,
|
|
39
|
+
kSecAttrAccount as String: request.account
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
switch request.operation {
|
|
44
|
+
case "get":
|
|
45
|
+
var query = baseQuery()
|
|
46
|
+
query[kSecReturnData as String] = true
|
|
47
|
+
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
48
|
+
var result: CFTypeRef?
|
|
49
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
50
|
+
if status == errSecItemNotFound {
|
|
51
|
+
write(KeychainResponse(found: false, value: nil))
|
|
52
|
+
} else if status == errSecSuccess, let data = result as? Data {
|
|
53
|
+
write(KeychainResponse(found: true, value: String(data: data, encoding: .utf8)))
|
|
54
|
+
} else {
|
|
55
|
+
fail(status)
|
|
56
|
+
}
|
|
57
|
+
case "set":
|
|
58
|
+
guard let value = request.value, !value.isEmpty else {
|
|
59
|
+
FileHandle.standardError.write(Data("A Keychain value is required".utf8))
|
|
60
|
+
exit(1)
|
|
61
|
+
}
|
|
62
|
+
let deleteStatus = SecItemDelete(baseQuery() as CFDictionary)
|
|
63
|
+
if deleteStatus != errSecSuccess && deleteStatus != errSecItemNotFound { fail(deleteStatus) }
|
|
64
|
+
var query = baseQuery()
|
|
65
|
+
query[kSecValueData as String] = Data(value.utf8)
|
|
66
|
+
query[kSecAttrLabel as String] = "Wicker Study Canvas token (\(request.account))"
|
|
67
|
+
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
68
|
+
let status = SecItemAdd(query as CFDictionary, nil)
|
|
69
|
+
if status == errSecSuccess { write(KeychainResponse(found: true, value: nil)) }
|
|
70
|
+
else { fail(status) }
|
|
71
|
+
case "delete":
|
|
72
|
+
let status = SecItemDelete(baseQuery() as CFDictionary)
|
|
73
|
+
if status == errSecSuccess { write(KeychainResponse(found: true, value: nil)) }
|
|
74
|
+
else if status == errSecItemNotFound { write(KeychainResponse(found: false, value: nil)) }
|
|
75
|
+
else { fail(status) }
|
|
76
|
+
default:
|
|
77
|
+
FileHandle.standardError.write(Data("Unknown Keychain operation".utf8))
|
|
78
|
+
exit(1)
|
|
79
|
+
}
|