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
This commit is contained in:
2026-06-01 19:41:34 -07:00
parent 25d874d87d
commit 5274bdbd51
5 changed files with 137 additions and 0 deletions
+58
View File
@@ -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='<password the user gave you>'; 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='<password the user gave you>' 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.
+42
View File
@@ -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 <file.enc>
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 <file.enc>'); 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);
}
+32
View File
@@ -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 <plaintextFile|-> <outFile.enc>
// (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 <plaintextFile|-> <outFile.enc>'); 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).`);
+1
View File
@@ -0,0 +1 @@
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
+4
View File
@@ -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