@heyquilt/seam 0.27.0 → 0.130.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.
Files changed (42) hide show
  1. package/bin/seam.js +117 -17
  2. package/lib/quilts/agent-tokens.js +148 -0
  3. package/lib/quilts/archiver.js +241 -0
  4. package/lib/quilts/attest.js +212 -0
  5. package/lib/quilts/bisect-run.js +3 -2
  6. package/lib/quilts/block.js +497 -55
  7. package/lib/quilts/cas.js +78 -0
  8. package/lib/quilts/cbor.js +2 -0
  9. package/lib/quilts/claims.js +409 -0
  10. package/lib/quilts/cli.js +611 -69
  11. package/lib/quilts/client.js +305 -19
  12. package/lib/quilts/conflict-format.js +266 -0
  13. package/lib/quilts/daemon.js +174 -0
  14. package/lib/quilts/describe.js +281 -38
  15. package/lib/quilts/dict.js +34 -0
  16. package/lib/quilts/exit-archive.js +184 -0
  17. package/lib/quilts/export-format.js +52 -0
  18. package/lib/quilts/export-git.js +27 -3
  19. package/lib/quilts/export-lfs.js +52 -0
  20. package/lib/quilts/export-sign.js +49 -0
  21. package/lib/quilts/fastcdc.js +186 -0
  22. package/lib/quilts/hash.js +17 -2
  23. package/lib/quilts/ignored.js +46 -0
  24. package/lib/quilts/import-git.js +54 -2
  25. package/lib/quilts/journal.js +105 -0
  26. package/lib/quilts/limits.js +197 -0
  27. package/lib/quilts/linepolicy.js +109 -0
  28. package/lib/quilts/pack.js +146 -18
  29. package/lib/quilts/persona.js +70 -0
  30. package/lib/quilts/records.js +7 -3
  31. package/lib/quilts/recover.js +37 -0
  32. package/lib/quilts/runner.js +79 -12
  33. package/lib/quilts/sealed.js +231 -0
  34. package/lib/quilts/secrets.js +86 -0
  35. package/lib/quilts/signing.js +7 -3
  36. package/lib/quilts/tarball.js +107 -0
  37. package/lib/quilts/textdiff.js +7 -5
  38. package/lib/quilts/tools.js +401 -83
  39. package/lib/quilts/tree.js +351 -0
  40. package/lib/quilts/want.js +51 -0
  41. package/lib/quilts/webhook.js +116 -0
  42. package/package.json +3 -2
@@ -0,0 +1,231 @@
1
+ // Sealed quilts (plans/quilt-source-control/11.11): the server never sees a sealed quilt's file
2
+ // contents. One data key per quilt and key version, made and held by its members' computers,
3
+ // wrapped for each member's X25519 key; the server keeps only the wraps. Blob bytes are sealed
4
+ // here (AES-256-GCM, magic QF1, the key version in the header) and their ids are keyed, so the
5
+ // server cannot test whether the quilt holds a file it can guess:
6
+ // id_key = BLAKE3 derive_key("quilt sealed id v1", data_key)
7
+ // id = BLAKE3 keyed(id_key, "quilt blob v1" ‖ plaintext)
8
+ // Tree nodes, paths, sizes and commit records stay plaintext: the server builds snapshots, checks
9
+ // protections and claims, and detects conflicts by path from them.
10
+ //
11
+ // This file is both sides: the codec a client seals and opens with, and the wrap format.
12
+ import crypto from 'node:crypto'
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import { blake3 } from '@noble/hashes/blake3.js'
17
+ import { diffMarkers } from './conflict-format.js'
18
+ import { squareShort } from './errors.js'
19
+
20
+ export const MAGIC = Buffer.from('QF1')
21
+ const HEAD = MAGIC.length + 4 + 12
22
+ const TAG = 16
23
+ export const SEALED_OVERHEAD = HEAD + TAG
24
+ const X25519_SPKI = Buffer.from('302a300506032b656e032100', 'hex')
25
+ const ID_CONTEXT = Buffer.from('quilt sealed id v1')
26
+ const BLOB_PREFIX = Buffer.from('quilt blob v1')
27
+ const WRAP_INFO = Buffer.from('seam sealed wrap v1')
28
+
29
+ /** The id key a data key gives (one per key version). */
30
+ export const idKeyOf = (dataKey) => blake3(Buffer.from(dataKey), { context: ID_CONTEXT })
31
+ /** A sealed quilt's blob id: keyed BLAKE3 of the plaintext. */
32
+ export const sealedId = (idKey, plaintext) => Buffer.from(blake3(Buffer.concat([BLOB_PREFIX, Buffer.from(plaintext)]), { key: idKey })).toString('hex')
33
+
34
+ /** Seals bytes under a key version: QF1 ‖ version u32 ‖ iv ‖ ciphertext ‖ tag. */
35
+ export function sealBytes (dataKey, version, plaintext) {
36
+ const iv = crypto.randomBytes(12)
37
+ const head = Buffer.alloc(MAGIC.length + 4)
38
+ MAGIC.copy(head); head.writeUInt32BE(version, MAGIC.length)
39
+ const c = crypto.createCipheriv('aes-256-gcm', Buffer.from(dataKey), iv)
40
+ c.setAAD(head)
41
+ const body = Buffer.concat([c.update(Buffer.from(plaintext)), c.final()])
42
+ return Buffer.concat([head, iv, body, c.getAuthTag()])
43
+ }
44
+
45
+ /** The key version a sealed blob was sealed under, or null when the bytes are not sealed. */
46
+ export function versionOf (sealed) {
47
+ const b = Buffer.from(sealed)
48
+ return b.length >= SEALED_OVERHEAD && b.subarray(0, MAGIC.length).equals(MAGIC) ? b.readUInt32BE(MAGIC.length) : null
49
+ }
50
+
51
+ export function openBytes (dataKey, sealed) {
52
+ const b = Buffer.from(sealed)
53
+ if (versionOf(b) == null) throw new Error('not a sealed blob')
54
+ const d = crypto.createDecipheriv('aes-256-gcm', Buffer.from(dataKey), b.subarray(MAGIC.length + 4, HEAD))
55
+ d.setAAD(b.subarray(0, MAGIC.length + 4))
56
+ d.setAuthTag(b.subarray(b.length - TAG))
57
+ return Buffer.concat([d.update(b.subarray(HEAD, b.length - TAG)), d.final()])
58
+ }
59
+
60
+ // ---- wraps: a data key for one member's X25519 key ----
61
+
62
+ const rawX25519 = (keyObject) => keyObject.export({ format: 'der', type: 'spki' }).subarray(X25519_SPKI.length)
63
+ const x25519Public = (raw) => crypto.createPublicKey({ key: Buffer.concat([X25519_SPKI, Buffer.from(raw, 'base64')]), format: 'der', type: 'spki' })
64
+ /** A sealing key's id: `x25519:` and 16 hex of the sha256 of its raw public bytes. */
65
+ export const sealingKeyId = (raw) => 'x25519:' + crypto.createHash('sha256').update(Buffer.from(raw, 'base64')).digest('hex').slice(0, 16)
66
+
67
+ function wrapKey (shared, epk, recipient) {
68
+ return Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.concat([epk, recipient]), WRAP_INFO, 32))
69
+ }
70
+
71
+ /** Wraps a data key for a member's X25519 public key (raw, base64): epk ‖ iv ‖ ciphertext ‖ tag. */
72
+ export function wrapFor (publicKey, dataKey) {
73
+ const eph = crypto.generateKeyPairSync('x25519')
74
+ const epk = rawX25519(eph.publicKey)
75
+ const recipient = Buffer.from(publicKey, 'base64')
76
+ const k = wrapKey(crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: x25519Public(publicKey) }), epk, recipient)
77
+ const iv = crypto.randomBytes(12)
78
+ const c = crypto.createCipheriv('aes-256-gcm', k, iv)
79
+ const body = Buffer.concat([c.update(Buffer.from(dataKey)), c.final()])
80
+ return Buffer.concat([epk, iv, body, c.getAuthTag()]).toString('base64')
81
+ }
82
+
83
+ export function unwrapWith (privateKey, wrap) {
84
+ const b = Buffer.from(wrap, 'base64')
85
+ const epk = b.subarray(0, 32)
86
+ const own = rawX25519(crypto.createPublicKey(privateKey))
87
+ const k = wrapKey(crypto.diffieHellman({ privateKey, publicKey: x25519Public(epk.toString('base64')) }), epk, own)
88
+ const d = crypto.createDecipheriv('aes-256-gcm', k, b.subarray(32, 44))
89
+ d.setAuthTag(b.subarray(b.length - TAG))
90
+ return Buffer.concat([d.update(b.subarray(44, b.length - TAG)), d.final()])
91
+ }
92
+
93
+ // ---- this computer's sealing key ----
94
+
95
+ /** Where this computer's sealing key lives: SEAM_SEALING_KEY, else ~/.seam/sealing-key.pem. */
96
+ export const sealingKeyFile = () => process.env.SEAM_SEALING_KEY || path.join(process.env.SEAM_HOME || path.join(os.homedir(), '.seam'), 'sealing-key.pem')
97
+
98
+ /** This computer's X25519 key, made on first use: { privateKey, public_key (raw base64), id }. */
99
+ export function localSealingKey (file = sealingKeyFile(), { make = true } = {}) {
100
+ if (!fs.existsSync(file)) {
101
+ if (!make) return null
102
+ const { privateKey } = crypto.generateKeyPairSync('x25519')
103
+ fs.mkdirSync(path.dirname(file), { recursive: true })
104
+ fs.writeFileSync(file, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })
105
+ }
106
+ const privateKey = crypto.createPrivateKey(fs.readFileSync(file))
107
+ const pub = rawX25519(crypto.createPublicKey(privateKey)).toString('base64')
108
+ return { privateKey, public_key: pub, id: sealingKeyId(pub) }
109
+ }
110
+
111
+ /**
112
+ * A codec for a sealed quilt from its data keys ({ version: key }): ids are keyed with the
113
+ * newest version's key, which also seals; open() reads any version and checks the id.
114
+ */
115
+ export function codecOf (keys) {
116
+ const versions = Object.keys(keys).map(Number).sort((a, b) => a - b)
117
+ if (!versions.length) throw new Error('no data key for this sealed quilt on this computer: ask a member who has it to run seam seal grant <your member id>')
118
+ const latest = versions[versions.length - 1]
119
+ const idKeys = new Map(versions.map((v) => [v, idKeyOf(keys[v])]))
120
+ return {
121
+ sealed: true,
122
+ version: latest,
123
+ idOf: (plaintext) => sealedId(idKeys.get(latest), plaintext),
124
+ seal: (plaintext) => sealBytes(keys[latest], latest, plaintext),
125
+ /** The plaintext of a sealed blob, checked against its id (a wrong object is reported, never used). */
126
+ open (id, sealed) {
127
+ const v = versionOf(sealed)
128
+ if (v == null || !keys[v]) throw new Error(`blob ${String(id).slice(0, 12)} is sealed under key version ${v}, which this computer does not hold`)
129
+ const plain = openBytes(keys[v], sealed)
130
+ if (sealedId(idKeys.get(v), plain) !== id) throw Object.assign(new Error(`blob ${String(id).slice(0, 12)} does not match its id after opening: reported, not used`), { code: 'id_mismatch', id })
131
+ return plain
132
+ },
133
+ plainSize: (sealedSize) => sealedSize - SEALED_OVERHEAD
134
+ }
135
+ }
136
+
137
+ /**
138
+ * The codec for `quilt` through `client`, or null when it is not sealed: this computer's
139
+ * sealing key opens the wraps the server holds for this member.
140
+ */
141
+ export async function codecFor (client, quilt, { keyFile } = {}) {
142
+ const s = await client.seal(quilt)
143
+ if (!s.sealed) return null
144
+ const me = localSealingKey(keyFile, { make: false })
145
+ if (!me) throw new Error('This quilt is sealed and this computer has no sealing key: run seam seal key, then ask a member who has the quilt\'s key to run seam seal grant <your member id>.')
146
+ const keys = {}
147
+ for (const w of s.wraps) if (w.key_id === me.id) { try { keys[w.version] = unwrapWith(me.privateKey, w.wrap) } catch {} }
148
+ return codecOf(keys)
149
+ }
150
+
151
+ /**
152
+ * A sealed quilt's conflict as text with markers, made on the member's computer from its term
153
+ * (the server cannot read the sides): the same `diff` markers the server writes for other quilts.
154
+ */
155
+ export async function materialiseSealed (term, termIdHex, readSide) {
156
+ const side = async (s) => (s && s.blob ? (await readSide(s.blob)).toString('utf8') : '')
157
+ const label = (s, which) => `${which} (${[s.by || 'someone', s.from_square && squareShort(s.from_square), s.from_commit && s.from_commit.slice(0, 8)].filter(Boolean).join(' · ')})`
158
+ const sides = await Promise.all(term.adds.map(async (s, i) => ({ text: s.blob ? await side(s) : null, label: label(s, i === 0 ? 'ours' : 'theirs'), by: s.by })))
159
+ return Buffer.from(diffMarkers({ sides, bases: await Promise.all(term.removes.map(side)), tag: `quilt-conflict ${termIdHex.slice(0, 12)}`, kind: term.kind }))
160
+ }
161
+
162
+ /** A `cat` answer's file as plaintext: opened with the codec when the quilt is sealed. */
163
+ export async function catBytes (client, quilt, r, codec) {
164
+ if (!r.sealed) return Buffer.from(r.content, 'base64')
165
+ if (!codec) throw new Error('This quilt is sealed and this computer holds no key for it.')
166
+ if (r.conflict) {
167
+ return materialiseSealed(r.term_record, r.term, async (id) => {
168
+ const { blobs } = await client.blobs(quilt, [id])
169
+ return codec.open(id, Buffer.from(blobs[id], 'base64'))
170
+ })
171
+ }
172
+ return codec.open(r.blob, Buffer.from(r.content, 'base64'))
173
+ }
174
+
175
+ /** This computer's sealing key, made and registered with the server on first use. */
176
+ export async function ensureSealingKey (client, { keyFile } = {}) {
177
+ const me = localSealingKey(keyFile)
178
+ await client.addSealingKey(me.public_key)
179
+ return me
180
+ }
181
+
182
+ /** A new sealed quilt's first key: made here, wrapped for this computer only. */
183
+ export async function startSealed (client, quilt, memberId, { keyFile } = {}) {
184
+ const me = await ensureSealingKey(client, { keyFile })
185
+ return client.grantSeal(quilt, [{ version: 1, member_id: memberId, key_id: me.id, wrap: wrapFor(me.public_key, crypto.randomBytes(32)) }])
186
+ }
187
+
188
+ /** After switching a quilt to sealed: makes its first key here, unless a key is already wrapped. */
189
+ export async function startSealedIfNew (client, quilt, opts) {
190
+ if ((await client.seal(quilt)).version) return null
191
+ return startSealed(client, quilt, (await client.quilt(quilt)).you.member_id, opts)
192
+ }
193
+
194
+ /** The quilt's data keys this computer holds, by version. */
195
+ async function heldKeys (client, quilt, keyFile) {
196
+ const s = await client.seal(quilt)
197
+ if (!s.sealed) throw new Error(`${quilt} is not sealed`)
198
+ const me = localSealingKey(keyFile, { make: false })
199
+ const keys = {}
200
+ if (me) for (const w of s.wraps) if (w.key_id === me.id) { try { keys[w.version] = unwrapWith(me.privateKey, w.wrap) } catch {} }
201
+ if (!keys[s.version]) throw new Error(`This computer does not hold ${quilt}'s key (version ${s.version}): a member who does runs seam seal grant <your member id>.`)
202
+ return { s, keys, me }
203
+ }
204
+
205
+ /** Wraps the quilt's current key for every sealing key `member` has registered. */
206
+ export async function grantSealed (client, quilt, member, { keyFile } = {}) {
207
+ const { s, keys } = await heldKeys(client, quilt, keyFile)
208
+ const { keys: theirs } = await client.sealingKeys(member)
209
+ if (!theirs.length) throw new Error(`${member} has no sealing key yet: they run seam seal key on their computer first.`)
210
+ return client.grantSeal(quilt, theirs.map((k) => ({ version: s.version, member_id: member, key_id: k.key_id, wrap: wrapFor(k.public_key, keys[s.version]) })))
211
+ }
212
+
213
+ /**
214
+ * A new key version, wrapped for everyone who holds the current one except `without` (removing
215
+ * a member): what they fetched stays readable to them, nothing sealed from now on is.
216
+ */
217
+ export async function rotateSealed (client, quilt, { without = [], keyFile } = {}) {
218
+ const { s } = await heldKeys(client, quilt, keyFile)
219
+ const next = crypto.randomBytes(32)
220
+ const wraps = []
221
+ const members = [...new Set(s.holders.map((h) => h.member_id))].filter((m) => !without.includes(m))
222
+ for (const m of members) for (const k of (await client.sealingKeys(m)).keys) wraps.push({ version: s.version + 1, member_id: m, key_id: k.key_id, wrap: wrapFor(k.public_key, next) })
223
+ return client.rotateSeal(quilt, s.version + 1, wraps)
224
+ }
225
+
226
+ /** A sealed quilt's state in a few lines. */
227
+ export function describeSeal (s) {
228
+ if (!s.sealed) return `${s.quilt} is not sealed.`
229
+ const who = [...new Set(s.holders.map((h) => h.member_id))]
230
+ return `${s.quilt} is sealed: key version ${s.version}, held by ${who.join(', ') || 'nobody yet'}. ${s.wraps.some((w) => w.version === s.version) ? 'You hold it.' : 'You do not hold it: ask one of them to run seam seal grant <your member id>.'}\nThe server sees paths, sizes, which files are equal, and commit details (author, message, times); never file contents.`
231
+ }
@@ -0,0 +1,86 @@
1
+ // Secret scanning at commit time (plans/quilt-source-control/11.9): a commit's new file contents
2
+ // are scanned before the transaction, O(changes), for known key shapes and for high-entropy
3
+ // values assigned to secret-sounding names. A finding names its path, line, rule and an id
4
+ // that is the same for the same value at the same path, so a person can let one through by its
5
+ // id; it never carries the value. `.env` files are refused outright (only `.env.example`
6
+ // passes). A Seam key found is revoked on the spot: Seam knows it is its own.
7
+ import crypto from 'node:crypto'
8
+ import { isText } from './textdiff.js'
9
+
10
+ export const MODES = ['refuse', 'warn', 'off']
11
+ export const MAX_SCAN = 2 * 1024 * 1024
12
+
13
+ /** Each rule: an id, what it is in words, and its pattern (group 1, when there is one, is the secret). */
14
+ export const RULES = [
15
+ { id: 'aws_access_key', what: 'an AWS access key', re: /\b((?:AKIA|ASIA)[0-9A-Z]{16})\b/g },
16
+ { id: 'github_token', what: 'a GitHub token', re: /\b(gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})\b/g },
17
+ { id: 'api_key_sk', what: 'an API secret key (sk-…)', re: /\b(sk-(?:proj-|ant-)?[A-Za-z0-9_-]{20,})/g },
18
+ { id: 'slack_token', what: 'a Slack token', re: /\b(xox[abprs]-[A-Za-z0-9-]{10,})/g },
19
+ { id: 'private_key', what: 'a private key', re: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----)/g },
20
+ { id: 'supabase_service_role', what: 'a Supabase service role key', re: /\b(eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/g, check: (v) => /service_role/.test(jwtPayload(v)) },
21
+ // Random key bodies, not names that happen to start the same way (qa_access_key_hash).
22
+ { id: 'quilt_key', what: 'a Quilt key', re: /\b(q[arskt]_[A-Za-z0-9_-]{20,})/g, check: (v) => entropy(v.slice(3)) >= 3.5 },
23
+ { id: 'seam_key', what: 'a Seam key', re: /\b((?:sqt|ssk|sak|sok|sor|sip)_[A-Za-z0-9_-]{20,})/g, check: (v) => entropy(v.slice(4)) >= 3.5 }
24
+ ]
25
+ const ASSIGN = /\b[\w.-]*(?:secret|token|passw(?:or)?d|api[_-]?key|private[_-]?key|access[_-]?key|credential)[\w.-]*["']?\s*[:=]\s*["']?([A-Za-z0-9+/_=.-]{20,})/gi
26
+
27
+ function jwtPayload (jwt) { try { return Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8') } catch { return '' } }
28
+
29
+ /** Shannon entropy in bits per character. */
30
+ export function entropy (s) {
31
+ const n = new Map()
32
+ for (const c of s) n.set(c, (n.get(c) || 0) + 1)
33
+ let h = 0
34
+ for (const k of n.values()) { const p = k / s.length; h -= p * Math.log2(p) }
35
+ return h
36
+ }
37
+
38
+ const findingId = (rule, path, value) => 'sf_' + crypto.createHash('sha256').update(`${rule}\0${path}\0${value}`).digest('hex').slice(0, 12)
39
+
40
+ /** Whether a path is a .env file (refused outright; .env.example is a template, not secrets). */
41
+ export const isEnvFile = (path) => { const b = path.split('/').pop(); return (b === '.env' || b.startsWith('.env.')) && b !== '.env.example' }
42
+
43
+ /**
44
+ * The findings in one file: [{ id, rule, what, path, line, allowable }], and the Seam keys seen
45
+ * (values, so the caller can revoke them; they never go into an answer).
46
+ */
47
+ export function scanFile (path, bytes) {
48
+ const findings = []
49
+ const seamKeys = []
50
+ if (isEnvFile(path)) findings.push({ id: findingId('env_file', path, ''), rule: 'env_file', what: 'a .env file', path, line: null, allowable: false })
51
+ if (!bytes || bytes.length > MAX_SCAN || !isText(bytes)) return { findings, seamKeys }
52
+ const lines = bytes.toString('utf8').split('\n')
53
+ for (let i = 0; i < lines.length; i++) {
54
+ const text = lines[i]
55
+ if (text.length > 10000) continue
56
+ const seen = new Set()
57
+ for (const r of RULES) {
58
+ r.re.lastIndex = 0
59
+ for (let m = r.re.exec(text); m; m = r.re.exec(text)) {
60
+ const v = m[1] || m[0]
61
+ if (r.check && !r.check(v)) continue
62
+ seen.add(v)
63
+ if (r.id === 'seam_key') seamKeys.push(v)
64
+ findings.push({ id: findingId(r.id, path, v), rule: r.id, what: r.what, path, line: i + 1, allowable: true })
65
+ }
66
+ }
67
+ ASSIGN.lastIndex = 0
68
+ for (let m = ASSIGN.exec(text); m; m = ASSIGN.exec(text)) {
69
+ const v = m[1]
70
+ if (seen.has(v) || entropy(v) < 4) continue
71
+ findings.push({ id: findingId('high_entropy', path, v), rule: 'high_entropy', what: 'a high-entropy value assigned to a secret-sounding name', path, line: i + 1, allowable: true })
72
+ }
73
+ }
74
+ return { findings, seamKeys }
75
+ }
76
+
77
+ /** The findings in a commit's new files ([{ path, bytes }]), with the Seam keys among them. */
78
+ export function scanFiles (files) {
79
+ const findings = []
80
+ const seamKeys = []
81
+ for (const f of files) { const r = scanFile(f.path, f.bytes); findings.push(...r.findings); seamKeys.push(...r.seamKeys) }
82
+ return { findings, seamKeys }
83
+ }
84
+
85
+ /** One line per finding, never the value. */
86
+ export const describeFinding = (f) => `${f.path}${f.line ? `:${f.line}` : ''}: ${f.what} (${f.id})`
@@ -38,8 +38,11 @@ const keyObject = (raw) => crypto.createPublicKey({ key: Buffer.concat([SPKI_PRE
38
38
  /** A key's id: `ed25519:` and the first 16 hex of the sha256 of its raw bytes. */
39
39
  export const keyId = (raw) => 'ed25519:' + crypto.createHash('sha256').update(Buffer.from(raw, 'base64')).digest('hex').slice(0, 16)
40
40
 
41
- export function verifySignature (raw, commitId, signature) {
42
- try { return crypto.verify(null, Buffer.from(signedText(commitId)), keyObject(raw), Buffer.from(String(signature), 'base64')) } catch { return false }
41
+ export function verifySignature (raw, commitId, signature) { return verifyText(raw, signedText(commitId), signature) }
42
+
43
+ /** Whether `signature` is `raw`'s over `text` (a commit, or a policy confirmation, 11.4). */
44
+ export function verifyText (raw, text, signature) {
45
+ try { return crypto.verify(null, Buffer.from(text), keyObject(raw), Buffer.from(String(signature), 'base64')) } catch { return false }
43
46
  }
44
47
 
45
48
  // ---- the client side: the private key on this computer ----
@@ -62,7 +65,8 @@ export function localKey (file = signingKeyFile()) {
62
65
  const priv = crypto.createPrivateKey(fs.readFileSync(file))
63
66
  const pub = crypto.createPublicKey(priv).export({ format: 'pem', type: 'spki' })
64
67
  const raw = rawPublicKey(pub)
65
- return { public_key: pub, key: keyId(raw), sign: (commitId) => crypto.sign(null, Buffer.from(signedText(commitId)), priv).toString('base64') }
68
+ const signText = (text) => crypto.sign(null, Buffer.from(text), priv).toString('base64')
69
+ return { public_key: pub, key: keyId(raw), sign: (commitId) => signText(signedText(commitId)), signText }
66
70
  }
67
71
 
68
72
  /** Signs a commit with this computer's key and sends the signature. */
@@ -0,0 +1,107 @@
1
+ // One commit as a tarball (14, "One commit as a tarball"): the bytes are a pure function of the
2
+ // entries and the time given. Entries sorted by path, mode from the tree entry (0644, 0755, or a
3
+ // symlink), uid and gid 0, no user or group names, one mtime for every entry, a pax header only
4
+ // where ustar cannot hold a path or a size, and gzip with no name and no timestamp.
5
+ import zlib from 'node:zlib'
6
+
7
+ const BLOCK = 512
8
+
9
+ function octal (n, width) {
10
+ return n.toString(8).padStart(width - 1, '0') + '\0'
11
+ }
12
+
13
+ function header ({ name, size, mode, type, link = '', mtime }) {
14
+ const b = Buffer.alloc(BLOCK)
15
+ b.write(name, 0, 100, 'utf8')
16
+ b.write(octal(mode, 8), 100, 'ascii')
17
+ b.write(octal(0, 8), 108, 'ascii')
18
+ b.write(octal(0, 8), 116, 'ascii')
19
+ b.write(octal(size, 12), 124, 'ascii')
20
+ b.write(octal(mtime, 12), 136, 'ascii')
21
+ b.fill(' ', 148, 156)
22
+ b.write(type, 156, 'ascii')
23
+ b.write(link, 157, 100, 'utf8')
24
+ b.write('ustar\0', 257, 'ascii')
25
+ b.write('00', 263, 'ascii')
26
+ let sum = 0
27
+ for (const x of b) sum += x
28
+ b.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'ascii')
29
+ return b
30
+ }
31
+
32
+ const pad = (n) => Buffer.alloc((BLOCK - (n % BLOCK)) % BLOCK)
33
+
34
+ // A pax record: "<len> <key>=<value>\n", where len counts the whole record including itself.
35
+ function paxRecord (key, value) {
36
+ const body = ` ${key}=${value}\n`
37
+ let len = Buffer.byteLength(body) + 1
38
+ while (String(len).length + Buffer.byteLength(body) !== len) len = String(len).length + Buffer.byteLength(body)
39
+ return `${len}${body}`
40
+ }
41
+
42
+ const fits = (s) => Buffer.byteLength(s) <= 100 && /^[\x20-\x7e]*$/.test(s)
43
+ const MAX_USTAR_SIZE = 8 ** 11 - 1
44
+
45
+ /**
46
+ * entries: [{ path, mode: 'file' | 'exec' | 'symlink', bytes }] (symlink: bytes are the target).
47
+ * mtime: seconds. Returns the .tar.gz as a Buffer.
48
+ */
49
+ export function tarGz (entries, { mtime = 0 } = {}) {
50
+ // gzip writes no name and, with no mtime given, a zero timestamp: the same bytes every time.
51
+ return zlib.gzipSync(tar(entries, { mtime }), { level: 9 })
52
+ }
53
+
54
+ /** The same entries as a plain .tar (the whole-quilt archive: its files are compressed already). */
55
+ export function tar (entries, { mtime = 0 } = {}) {
56
+ const parts = []
57
+ const sorted = [...entries].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
58
+ for (const e of sorted) {
59
+ const link = e.mode === 'symlink' ? Buffer.from(e.bytes).toString('utf8') : ''
60
+ const size = e.mode === 'symlink' ? 0 : e.bytes.length
61
+ const pax = []
62
+ if (!fits(e.path)) pax.push(paxRecord('path', e.path))
63
+ if (link && !fits(link)) pax.push(paxRecord('linkpath', link))
64
+ if (size > MAX_USTAR_SIZE) pax.push(paxRecord('size', String(size)))
65
+ if (pax.length) {
66
+ const body = Buffer.from(pax.join(''), 'utf8')
67
+ parts.push(header({ name: 'PaxHeader', size: body.length, mode: 0o644, type: 'x', mtime }), body, pad(body.length))
68
+ }
69
+ const name = fits(e.path) ? e.path : Buffer.from(e.path).subarray(0, 100).toString('latin1').replace(/[^\x20-\x7e]/g, '_')
70
+ parts.push(header({
71
+ name,
72
+ size: size > MAX_USTAR_SIZE ? 0 : size,
73
+ mode: e.mode === 'exec' ? 0o755 : e.mode === 'symlink' ? 0o777 : 0o644,
74
+ type: e.mode === 'symlink' ? '2' : '0',
75
+ link: fits(link) ? link : '',
76
+ mtime
77
+ }))
78
+ if (size) parts.push(Buffer.from(e.bytes), pad(size))
79
+ }
80
+ parts.push(Buffer.alloc(BLOCK * 2))
81
+ return Buffer.concat(parts)
82
+ }
83
+
84
+ /** Reads a plain .tar back: [{ path, bytes }] for regular files (ustar with pax path and size). */
85
+ export function untar (buf) {
86
+ const out = []
87
+ let pax = {}
88
+ for (let at = 0; at + BLOCK <= buf.length;) {
89
+ const h = buf.subarray(at, at + BLOCK)
90
+ if (h.every((x) => x === 0)) break
91
+ const field = (from, len) => h.subarray(from, from + len).toString('utf8').replace(/\0.*$/s, '')
92
+ const type = field(156, 1) || '0'
93
+ let size = parseInt(field(124, 12).trim() || '0', 8)
94
+ if (pax.size) size = Number(pax.size)
95
+ const body = buf.subarray(at + BLOCK, at + BLOCK + size)
96
+ if (body.length !== size) throw new Error('the tar ends inside an entry')
97
+ at += BLOCK + size + ((BLOCK - (size % BLOCK)) % BLOCK)
98
+ if (type === 'x') {
99
+ pax = {}
100
+ for (const rec of body.toString('utf8').split('\n')) { const m = /^\d+ ([^=]+)=(.*)$/s.exec(rec); if (m) pax[m[1]] = m[2] }
101
+ continue
102
+ }
103
+ if (type === '0') out.push({ path: pax.path || field(0, 100), bytes: Buffer.from(body) })
104
+ pax = {}
105
+ }
106
+ return out
107
+ }
@@ -32,21 +32,23 @@ export function hunks (a, b) {
32
32
  }
33
33
 
34
34
  /** +added −removed counts between two texts. */
35
- export function lineStat (a, b) {
35
+ export function lineStat (a, b, { normalize } = {}) {
36
36
  let add = 0; let del = 0
37
- for (const h of hunks(splitLines(a), splitLines(b))) { del += h.a[1]; add += h.b[1] }
37
+ const [la, lb] = [splitLines(a), splitLines(b)]
38
+ for (const h of normalize ? hunks(la.map(normalize), lb.map(normalize)) : hunks(la, lb)) { del += h.a[1]; add += h.b[1] }
38
39
  return { add, del }
39
40
  }
40
41
 
41
42
  /**
42
43
  * A unified diff of one file (git's format, 3 lines of context). `before`/`after` are strings,
43
- * or null when the file is added or deleted.
44
+ * or null when the file is added or deleted. `normalize` maps a line to what it compares as.
44
45
  */
45
- export function unified (path, before, after, { context = 3, fromPath = path } = {}) {
46
+ export function unified (path, before, after, { context = 3, fromPath = path, normalize } = {}) {
46
47
  const a = splitLines(before ?? '')
47
48
  const b = splitLines(after ?? '')
48
49
  const head = [`--- ${before == null ? '/dev/null' : `a/${fromPath}`}`, `+++ ${after == null ? '/dev/null' : `b/${path}`}`]
49
- const hs = hunks(a, b)
50
+ // normalize (ignore_whitespace): lines compare by it; the hunks still show the lines as they are.
51
+ const hs = normalize ? hunks(a.map(normalize), b.map(normalize)) : hunks(a, b)
50
52
  if (!hs.length) return ''
51
53
  const out = [...head]
52
54
  // Group hunks whose context overlaps.