From 5274bdbd51c1279a55a0941b4294e961ccf7b988 Mon Sep 17 00:00:00 2001 From: Justin Parker Date: Mon, 1 Jun 2026 19:41:34 -0700 Subject: [PATCH] Add password-protected sync-repo skill with encrypted body Adds a project-local /sync-repo skill whose real instructions are encrypted at rest (AES-256-GCM + scrypt) in sync-repo.enc and only revealed in-session after the user supplies the correct passphrase. Plaintext SYNC.md is gitignored and never committed; *.enc is marked binary to protect the ciphertext bytes. AI Assisted --- .claude/skills/sync-repo/SKILL.md | 58 ++++++++++++++++++++++++++ .claude/skills/sync-repo/decrypt.mjs | 42 +++++++++++++++++++ .claude/skills/sync-repo/encrypt.mjs | 32 ++++++++++++++ .claude/skills/sync-repo/sync-repo.enc | 1 + .gitattributes | 4 ++ 5 files changed, 137 insertions(+) create mode 100644 .claude/skills/sync-repo/SKILL.md create mode 100644 .claude/skills/sync-repo/decrypt.mjs create mode 100644 .claude/skills/sync-repo/encrypt.mjs create mode 100644 .claude/skills/sync-repo/sync-repo.enc create mode 100644 .gitattributes diff --git a/.claude/skills/sync-repo/SKILL.md b/.claude/skills/sync-repo/SKILL.md new file mode 100644 index 0000000..088771e --- /dev/null +++ b/.claude/skills/sync-repo/SKILL.md @@ -0,0 +1,58 @@ +--- +name: sync-repo +description: "Run the encrypted, password-protected repository sync workflow for this project. The real instructions are stored encrypted at rest and are only revealed in-session after you supply the correct password. Invoke explicitly with /sync-repo." +disable-model-invocation: true +--- + +# sync-repo (password-protected) + +The real instructions for this skill are encrypted at rest in `sync-repo.enc` +and are NOT readable without the password. Do not guess, reconstruct, or invent +the workflow. Follow this launcher exactly. + +## What you (the agent) must do + +1. **Ask the user for the password.** Request the decryption passphrase (via + AskUserQuestion or a plain prompt). Do NOT proceed without it. Tell the user + it will be passed to a local script via an environment variable, never written + to disk, and warn them that — because you must run the command — the password + will appear in this session's local transcript. (It never enters the repo.) + +2. **Decrypt to STDOUT only.** Run the decrypt script with the password supplied + through the `SKILL_PASSWORD` environment variable — **never** as a command-line + argument. From the repo root: + + - **Windows PowerShell:** + ```powershell + $env:SKILL_PASSWORD=''; node .\.claude\skills\sync-repo\decrypt.mjs .\.claude\skills\sync-repo\sync-repo.enc; Remove-Item Env:\SKILL_PASSWORD + ``` + - **bash / macOS / Linux:** + ```bash + SKILL_PASSWORD='' node ./.claude/skills/sync-repo/decrypt.mjs ./.claude/skills/sync-repo/sync-repo.enc + ``` + +3. **Handle the result.** + - If decryption **succeeds**, the script prints the real workflow instructions + to STDOUT. Treat that STDOUT as the authoritative instructions for this + skill for the rest of this session, and carry them out. + - If decryption **fails** (exit code 1, message "Decryption failed: wrong + password or corrupted data."), the password was wrong or the file is + corrupt. Tell the user, ask them to re-enter the password, and retry. Do + NOT attempt to reconstruct the instructions from anything else. + +## Hard rules + +- **Never write the decrypted plaintext to a file.** Read it from STDOUT only. + `decrypt.mjs` intentionally has no file-output mode. +- **Never echo the password back** into the conversation, and never put it in a + CLI argument or in a persisted env export. +- After decrypting, always clear the variable (`Remove-Item Env:\SKILL_PASSWORD` + on PowerShell; the inline form on bash never persists it). + +## Security note (be honest with the user) + +This protects the workflow body **only at rest in the repository**. Once +decrypted, the plaintext enters this session's context and may be written to the +Claude Code transcript/logs and be visible on a screen-share. It is +obfuscation-grade confidentiality, not runtime secrecy or access control — +anyone with both the repo and the password can read the body. diff --git a/.claude/skills/sync-repo/decrypt.mjs b/.claude/skills/sync-repo/decrypt.mjs new file mode 100644 index 0000000..17873f3 --- /dev/null +++ b/.claude/skills/sync-repo/decrypt.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// decrypt.mjs — verifies GCM auth tag, prints plaintext to STDOUT only. +// Password from $SKILL_PASSWORD. Exits 1 on wrong password / tamper, leaking nothing. +// Usage: SKILL_PASSWORD=... node decrypt.mjs +import { readFileSync } from 'node:fs'; +import { scryptSync, createDecipheriv } from 'node:crypto'; + +const MAGIC = Buffer.from('SENC', 'ascii'); +const VERSION = 0x01; +const SCRYPT = { N: 1 << 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }; +const KEYLEN = 32, SALTLEN = 16, IVLEN = 12, TAGLEN = 16; +const HEADER = MAGIC.length + 1; // 5 + +const password = process.env.SKILL_PASSWORD; +if (!password) { console.error('ERROR: set SKILL_PASSWORD env var.'); process.exit(2); } + +const encPath = process.argv[2]; +if (!encPath) { console.error('Usage: node decrypt.mjs '); process.exit(2); } + +try { + const blob = Buffer.from(readFileSync(encPath, 'utf8').trim(), 'base64'); + if (blob.length < HEADER + SALTLEN + IVLEN + TAGLEN) throw new Error('truncated'); + if (!blob.subarray(0, MAGIC.length).equals(MAGIC)) throw new Error('bad magic'); + if (blob[MAGIC.length] !== VERSION) throw new Error('unsupported version'); + + let off = HEADER; + const salt = blob.subarray(off, off += SALTLEN); + const iv = blob.subarray(off, off += IVLEN); + const authTag = blob.subarray(off, off += TAGLEN); + const ciphertext = blob.subarray(off); + + const key = scryptSync(password, salt, KEYLEN, SCRYPT); + const decipher = createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(authTag); + // final() throws here if the tag does not verify (wrong password or tampering). + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + process.stdout.write(plaintext); // STDOUT only — never written to disk. +} catch (err) { + // Generic message: do not echo crypto internals or any plaintext. + console.error('Decryption failed: wrong password or corrupted data.'); + process.exit(1); +} diff --git a/.claude/skills/sync-repo/encrypt.mjs b/.claude/skills/sync-repo/encrypt.mjs new file mode 100644 index 0000000..9331807 --- /dev/null +++ b/.claude/skills/sync-repo/encrypt.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +// encrypt.mjs — AES-256-GCM + scrypt. Password from $SKILL_PASSWORD (never argv). +// Usage: SKILL_PASSWORD=... node encrypt.mjs +// (pass '-' or omit the input path to read plaintext from STDIN) +import { readFileSync, writeFileSync } from 'node:fs'; +import { scryptSync, randomBytes, createCipheriv } from 'node:crypto'; + +const MAGIC = Buffer.from('SENC', 'ascii'); +const VERSION = 0x01; +const SCRYPT = { N: 1 << 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }; +const KEYLEN = 32, SALTLEN = 16, IVLEN = 12; + +const password = process.env.SKILL_PASSWORD; +if (!password) { console.error('ERROR: set SKILL_PASSWORD env var.'); process.exit(2); } + +const inPath = process.argv[2]; +const outPath = process.argv[3]; +if (!outPath) { console.error('Usage: node encrypt.mjs '); process.exit(2); } + +// readFileSync(0) reads STDIN; use it when no input file (or '-') is given. +const plaintext = (!inPath || inPath === '-') ? readFileSync(0) : readFileSync(inPath); + +const salt = randomBytes(SALTLEN); +const iv = randomBytes(IVLEN); +const key = scryptSync(password, salt, KEYLEN, SCRYPT); +const cipher = createCipheriv('aes-256-gcm', key, iv); +const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); +const authTag = cipher.getAuthTag(); // 16 bytes + +const blob = Buffer.concat([MAGIC, Buffer.from([VERSION]), salt, iv, authTag, ciphertext]); +writeFileSync(outPath, blob.toString('base64') + '\n'); +console.error(`Wrote ${outPath} (${blob.length} raw bytes, base64-encoded).`); diff --git a/.claude/skills/sync-repo/sync-repo.enc b/.claude/skills/sync-repo/sync-repo.enc new file mode 100644 index 0000000..23c18be --- /dev/null +++ b/.claude/skills/sync-repo/sync-repo.enc @@ -0,0 +1 @@ +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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2bf2dc4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# The encrypted sync-repo blob is base64 text but must never have its bytes +# altered by line-ending normalization. Treat it as binary so autocrlf/eol +# settings can never corrupt the ciphertext. +*.enc binary