@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.
- package/bin/seam.js +117 -17
- package/lib/quilts/agent-tokens.js +148 -0
- package/lib/quilts/archiver.js +241 -0
- package/lib/quilts/attest.js +212 -0
- package/lib/quilts/bisect-run.js +3 -2
- package/lib/quilts/block.js +497 -55
- package/lib/quilts/cas.js +78 -0
- package/lib/quilts/cbor.js +2 -0
- package/lib/quilts/claims.js +409 -0
- package/lib/quilts/cli.js +611 -69
- package/lib/quilts/client.js +305 -19
- package/lib/quilts/conflict-format.js +266 -0
- package/lib/quilts/daemon.js +174 -0
- package/lib/quilts/describe.js +281 -38
- package/lib/quilts/dict.js +34 -0
- package/lib/quilts/exit-archive.js +184 -0
- package/lib/quilts/export-format.js +52 -0
- package/lib/quilts/export-git.js +27 -3
- package/lib/quilts/export-lfs.js +52 -0
- package/lib/quilts/export-sign.js +49 -0
- package/lib/quilts/fastcdc.js +186 -0
- package/lib/quilts/hash.js +17 -2
- package/lib/quilts/ignored.js +46 -0
- package/lib/quilts/import-git.js +54 -2
- package/lib/quilts/journal.js +105 -0
- package/lib/quilts/limits.js +197 -0
- package/lib/quilts/linepolicy.js +109 -0
- package/lib/quilts/pack.js +146 -18
- package/lib/quilts/persona.js +70 -0
- package/lib/quilts/records.js +7 -3
- package/lib/quilts/recover.js +37 -0
- package/lib/quilts/runner.js +79 -12
- package/lib/quilts/sealed.js +231 -0
- package/lib/quilts/secrets.js +86 -0
- package/lib/quilts/signing.js +7 -3
- package/lib/quilts/tarball.js +107 -0
- package/lib/quilts/textdiff.js +7 -5
- package/lib/quilts/tools.js +401 -83
- package/lib/quilts/tree.js +351 -0
- package/lib/quilts/want.js +51 -0
- package/lib/quilts/webhook.js +116 -0
- package/package.json +3 -2
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Server attestation (plans/quilt-source-control/11.7): every log row is signed by the server
|
|
2
|
+
// after it commits, outside the quilt row lock, with an Ed25519 key only Seam's server holds. An
|
|
3
|
+
// attestation names the row's quilt, its dense seq, the hash of its canonical encoding, who wrote
|
|
4
|
+
// it (and for whom, and with which quilt token), and when. So a row edited in the database fails
|
|
5
|
+
// its signature, an inserted one collides with a signed seq or arrives unsigned, and a removed
|
|
6
|
+
// one leaves a gap: anyone with the published keys can check a quilt's log offline.
|
|
7
|
+
//
|
|
8
|
+
// The API process that wrote a row signs it as soon as its write returns. A row found unsigned
|
|
9
|
+
// later than LATE_MS after it was written (its server died first) is signed by whoever finds it
|
|
10
|
+
// and marked `late`: that proves only that the row was in the database when it was read.
|
|
11
|
+
//
|
|
12
|
+
// Member signatures (signing.js) stay alongside as the co-signature: a person's own key over a
|
|
13
|
+
// commit id, which the server can never forge (ruling in 11.7).
|
|
14
|
+
import crypto from 'node:crypto'
|
|
15
|
+
import { blake3 } from '@noble/hashes/blake3.js'
|
|
16
|
+
import { QuiltError } from './errors.js'
|
|
17
|
+
|
|
18
|
+
export const LATE_MS = 60 * 1000
|
|
19
|
+
export const SWEEP_MS = 30 * 1000
|
|
20
|
+
const BATCH = 500
|
|
21
|
+
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Canonical JSON: keys sorted at every level, and a key whose value is null or undefined left out
|
|
25
|
+
* (as Postgres' jsonb_strip_nulls does), so a row reads the same from either store.
|
|
26
|
+
*/
|
|
27
|
+
export function canonical (v) {
|
|
28
|
+
if (Array.isArray(v)) return '[' + v.map((x) => x === undefined ? 'null' : canonical(x)).join(',') + ']'
|
|
29
|
+
if (v && typeof v === 'object') return '{' + Object.keys(v).filter((k) => v[k] != null).sort().map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}'
|
|
30
|
+
return JSON.stringify(v ?? null)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The fields of a log row that it is (03); `checks`, the one mutable column, is left out.
|
|
34
|
+
const ROW_FIELDS = ['quilt_id', 'seq', 'kind', 'commit', 'square', 'line', 'patch_no', 'from', 'to', 'actor', 'at', 'snapshot_root', 'payload']
|
|
35
|
+
|
|
36
|
+
/** A log row's canonical bytes: the same from the memory store and Postgres. */
|
|
37
|
+
export function rowBytes (row) {
|
|
38
|
+
const out = {}
|
|
39
|
+
for (const k of ROW_FIELDS) if (row[k] != null) out[k] = row[k]
|
|
40
|
+
if (!out.payload) out.payload = {}
|
|
41
|
+
return Buffer.from(canonical(out))
|
|
42
|
+
}
|
|
43
|
+
export const rowHash = (row) => Buffer.from(blake3(rowBytes(row), { context: Buffer.from('seam log row v1') })).toString('hex')
|
|
44
|
+
|
|
45
|
+
const SIGNED = ['quilt_id', 'seq', 'kind', 'row_hash', 'commit', 'author', 'on_behalf_of', 'token_id', 'at', 'late', 'key_id']
|
|
46
|
+
/** What a signature covers: the attestation's fields, canonical, after a context line. */
|
|
47
|
+
export const attestationText = (a) => 'seam-attestation-v1\n' + canonical(Object.fromEntries(SIGNED.map((k) => [k, a[k] ?? null])))
|
|
48
|
+
|
|
49
|
+
/** A key's id: `ed25519:` and the first 16 hex of the sha256 of its raw public bytes. */
|
|
50
|
+
const keyIdOf = (raw) => 'ed25519:' + crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The server's attestation key from SEAM_ATTESTATION_KEY (an Ed25519 private key: PKCS8 PEM, or
|
|
54
|
+
* base64 of its PKCS8 DER), or a new one for this process when none is given.
|
|
55
|
+
*/
|
|
56
|
+
export function attestationKey (secret) {
|
|
57
|
+
let key
|
|
58
|
+
if (secret) {
|
|
59
|
+
const s = String(secret).trim()
|
|
60
|
+
key = s.includes('BEGIN') ? crypto.createPrivateKey(s) : crypto.createPrivateKey({ key: Buffer.from(s, 'base64'), format: 'der', type: 'pkcs8' })
|
|
61
|
+
if (key.asymmetricKeyType !== 'ed25519') throw new Error('SEAM_ATTESTATION_KEY is not an Ed25519 private key')
|
|
62
|
+
} else key = crypto.generateKeyPairSync('ed25519').privateKey
|
|
63
|
+
const raw = crypto.createPublicKey(key).export({ format: 'der', type: 'spki' }).subarray(SPKI_PREFIX.length)
|
|
64
|
+
return { id: keyIdOf(raw), public_key: raw.toString('base64'), ephemeral: !secret, sign: (text) => crypto.sign(null, Buffer.from(text), key).toString('base64') }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether `a` is a good attestation of `row` under one of `keys` ([{ id, public_key }]). */
|
|
68
|
+
export function verifyAttestation (a, row, keys) {
|
|
69
|
+
if (!a) return 'unsigned'
|
|
70
|
+
if (a.seq !== row.seq || a.kind !== row.kind || a.row_hash !== rowHash(row)) return 'mismatch'
|
|
71
|
+
const k = keys.find((x) => x.id === a.key_id)
|
|
72
|
+
if (!k) return 'unknown_key'
|
|
73
|
+
try {
|
|
74
|
+
const pub = crypto.createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(k.public_key, 'base64')]), format: 'der', type: 'spki' })
|
|
75
|
+
if (!crypto.verify(null, Buffer.from(attestationText(a)), pub, Buffer.from(String(a.sig), 'base64'))) return 'bad_signature'
|
|
76
|
+
} catch { return 'bad_signature' }
|
|
77
|
+
return a.late ? 'late' : 'ok'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Checks a run of log rows against their attestations: { checked, ok, late, unsigned, bad, gaps }
|
|
82
|
+
* (each a list of seqs, gaps as [from, to] where seqs are missing between rows).
|
|
83
|
+
*/
|
|
84
|
+
export function verifyLog (rows, attestations, keys) {
|
|
85
|
+
const bySeq = new Map(attestations.map((a) => [a.seq, a]))
|
|
86
|
+
const out = { checked: rows.length, ok: 0, late: [], unsigned: [], bad: [], gaps: [] }
|
|
87
|
+
let prev = null
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
if (prev != null && row.seq !== prev + 1) out.gaps.push([prev + 1, row.seq - 1])
|
|
90
|
+
prev = row.seq
|
|
91
|
+
const v = verifyAttestation(bySeq.get(row.seq), row, keys)
|
|
92
|
+
if (v === 'ok') out.ok++
|
|
93
|
+
else if (v === 'late') { out.ok++; out.late.push(row.seq) } else if (v === 'unsigned') out.unsigned.push(row.seq)
|
|
94
|
+
else out.bad.push({ seq: row.seq, why: v })
|
|
95
|
+
}
|
|
96
|
+
// A signed seq with no row: the row was removed after it was signed.
|
|
97
|
+
const seen = new Set(rows.map((r) => r.seq))
|
|
98
|
+
const lo = rows.length ? rows[0].seq : 0
|
|
99
|
+
const hi = rows.length ? rows[rows.length - 1].seq : 0
|
|
100
|
+
for (const a of attestations) if (a.seq >= lo && a.seq <= hi && !seen.has(a.seq)) out.bad.push({ seq: a.seq, why: 'row_missing' })
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The signer: attest(qid) signs every row of the quilt still unsigned (from the lowest one this
|
|
106
|
+
* process has not yet seen signed), sweep() does it for each quilt this process has written.
|
|
107
|
+
*/
|
|
108
|
+
export function createAttester ({ store, now = Date.now, key, log = () => {} }) {
|
|
109
|
+
const floor = new Map() // quilt → seq below which every row is known signed
|
|
110
|
+
const running = new Map()
|
|
111
|
+
async function attestOnce (qid) {
|
|
112
|
+
for (;;) {
|
|
113
|
+
const rows = await store.unattested(qid, { after: floor.get(qid) || 0, limit: BATCH })
|
|
114
|
+
if (!rows.length) return
|
|
115
|
+
const at = now()
|
|
116
|
+
const out = []
|
|
117
|
+
for (const row of rows) {
|
|
118
|
+
const c = row.kind === 'commit' || row.kind === 'merge' ? (row.commit ? await store.getCommit(qid, row.commit) : null) : null
|
|
119
|
+
const a = {
|
|
120
|
+
quilt_id: qid, seq: row.seq, kind: row.kind, row_hash: rowHash(row), commit: row.commit || null,
|
|
121
|
+
author: row.actor, on_behalf_of: c?.on_behalf_of || null,
|
|
122
|
+
token_id: String(row.actor || '').startsWith('token:') ? row.actor.slice(6) : null,
|
|
123
|
+
at: row.at, late: at - row.at > LATE_MS, key_id: key.id
|
|
124
|
+
}
|
|
125
|
+
out.push({ ...a, sig: key.sign(attestationText(a)), signed_at: at })
|
|
126
|
+
}
|
|
127
|
+
await store.addAttestations({ quilt_id: qid, rows: out })
|
|
128
|
+
const late = out.filter((a) => a.late).length
|
|
129
|
+
if (late) log(`seam attestation: signed ${late} late row${late === 1 ? '' : 's'} of quilt ${qid}`)
|
|
130
|
+
floor.set(qid, rows[rows.length - 1].seq)
|
|
131
|
+
if (rows.length < BATCH) return
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Signs the quilt's unsigned rows; one pass at a time per quilt, a second call waits for it. */
|
|
135
|
+
function attest (qid) {
|
|
136
|
+
const prev = running.get(qid) || Promise.resolve()
|
|
137
|
+
const next = prev.then(() => attestOnce(qid)).catch((err) => log(`seam attestation: ${qid}: ${err.message}`))
|
|
138
|
+
running.set(qid, next)
|
|
139
|
+
next.finally(() => { if (running.get(qid) === next) running.delete(qid) })
|
|
140
|
+
return next
|
|
141
|
+
}
|
|
142
|
+
async function sweep () { for (const qid of floor.keys()) await attest(qid) }
|
|
143
|
+
let timer = null
|
|
144
|
+
return {
|
|
145
|
+
key,
|
|
146
|
+
attest,
|
|
147
|
+
sweep,
|
|
148
|
+
start () { if (!timer) { timer = setInterval(() => sweep().catch(() => {}), SWEEP_MS); timer.unref?.() } },
|
|
149
|
+
stop () { if (timer) clearInterval(timer); timer = null }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** GET /attestations, GET /verify and the published keys. */
|
|
154
|
+
export function attestationOps (svc, attester) {
|
|
155
|
+
const { store, quiltFor } = svc.internals
|
|
156
|
+
const keys = () => attester ? [{ id: attester.key.id, public_key: attester.key.public_key, use: 'attestation' }] : []
|
|
157
|
+
|
|
158
|
+
async function readable (me, ref) {
|
|
159
|
+
const q = await quiltFor(me, ref, 'read')
|
|
160
|
+
// Log rows name paths and squares: only a caller who may read the whole quilt gets them raw.
|
|
161
|
+
if (q.access.restricted) throw new QuiltError(403, 'protections', 'your reads in this quilt are limited by path, so its raw log is not yours to read', { need: 'read' })
|
|
162
|
+
if (!attester) throw new QuiltError(409, 'attestation_off', 'this Seam server does not sign its log (start it with SEAM_ATTESTATION=1)')
|
|
163
|
+
return q
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** GET /attestations?after=&limit=: each log row with its attestation (null while unsigned). */
|
|
167
|
+
async function attestations (me, ref, { after = 0, limit = 200 } = {}) {
|
|
168
|
+
const q = await readable(me, ref)
|
|
169
|
+
await attester.attest(q.id)
|
|
170
|
+
const max = Math.min(Math.max(Number(limit) || 200, 1), 1000)
|
|
171
|
+
const rows = await store.logRows(q.id, { after: Number(after) || 0, limit: max })
|
|
172
|
+
const signed = rows.length ? await store.attestationsOf(q.id, { from: rows[0].seq, to: rows[rows.length - 1].seq }) : []
|
|
173
|
+
const bySeq = new Map(signed.map((a) => [a.seq, a]))
|
|
174
|
+
return { quilt: q.slug, quilt_id: q.id, keys: keys(), rows: rows.map((row) => ({ row, attestation: bySeq.get(row.seq) || null })), more: rows.length === max }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** GET /verify?after=&limit=: the server's own check of its log against the published keys. */
|
|
178
|
+
async function verify (me, ref, opts = {}) {
|
|
179
|
+
const r = await attestations(me, ref, opts)
|
|
180
|
+
return { quilt: r.quilt, ...verifyLog(r.rows.map((x) => x.row), r.rows.map((x) => x.attestation).filter(Boolean), r.keys), more: r.more }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { attestations, verify, attestationKeys: async () => ({ keys: keys() }) }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* `seam verify`: reads a quilt's log and attestations through `client` and checks them here,
|
|
188
|
+
* against `keys` (pinned by the caller) or the server's published keys.
|
|
189
|
+
*/
|
|
190
|
+
export async function verifyQuilt (client, quilt, { after = 0, keys = null } = {}) {
|
|
191
|
+
const pinned = keys || (await client.attestationKeys()).keys
|
|
192
|
+
const rows = []; const atts = []
|
|
193
|
+
let from = Number(after) || 0
|
|
194
|
+
for (;;) {
|
|
195
|
+
const r = await client.attestations(quilt, { after: from, limit: 1000 })
|
|
196
|
+
for (const x of r.rows) { rows.push(x.row); if (x.attestation) atts.push(x.attestation) }
|
|
197
|
+
if (!r.more || !r.rows.length) break
|
|
198
|
+
from = r.rows[r.rows.length - 1].row.seq
|
|
199
|
+
}
|
|
200
|
+
return { quilt, keys: pinned.map((k) => k.id), ...verifyLog(rows, atts, pinned) }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A verification in a few lines. */
|
|
204
|
+
export function describeVerify (v) {
|
|
205
|
+
const head = `Checked ${v.checked} log row${v.checked === 1 ? '' : 's'} of ${v.quilt} against ${v.keys.join(', ') || 'no keys'}: ${v.ok} signed`
|
|
206
|
+
const lines = [head + (v.late.length ? ` (${v.late.length} late: ${v.late.slice(0, 10).join(', ')}${v.late.length > 10 ? ', …' : ''})` : '') + '.']
|
|
207
|
+
if (v.unsigned.length) lines.push(`Not signed yet: ${v.unsigned.slice(0, 20).join(', ')}${v.unsigned.length > 20 ? ', …' : ''}.`)
|
|
208
|
+
for (const b of v.bad.slice(0, 20)) lines.push(`Row ${b.seq}: ${{ mismatch: 'its row is not the row that was signed (changed after it was written)', bad_signature: 'its signature does not check out', unknown_key: 'signed with a key not in the list', row_missing: 'signed, but the row is gone' }[b.why] || b.why}.`)
|
|
209
|
+
for (const [a, b] of v.gaps.slice(0, 20)) lines.push(`Rows ${a === b ? a : `${a} to ${b}`} are missing.`)
|
|
210
|
+
if (!v.unsigned.length && !v.bad.length && !v.gaps.length) lines.push('Every row is the row the server signed, and none is missing.')
|
|
211
|
+
return lines.join('\n')
|
|
212
|
+
}
|
package/lib/quilts/bisect-run.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The check side of bisect (the server narrows, history.js): each commit the server names is
|
|
2
2
|
// checked out into a fresh folder and the check command runs there. Exit 0 is good, 125 is skip,
|
|
3
3
|
// 1 to 127 is bad, anything else (a signal, 128 and up) stops, as git bisect run does.
|
|
4
|
+
// `paths` are checked out; `only` narrows the commits tested to those that touched them.
|
|
4
5
|
import fs from 'node:fs'
|
|
5
6
|
import os from 'node:os'
|
|
6
7
|
import path from 'node:path'
|
|
@@ -13,10 +14,10 @@ const runIn = (cmd, cwd) => new Promise((resolve, reject) => {
|
|
|
13
14
|
child.on('exit', (code, signal) => resolve(signal ? null : code))
|
|
14
15
|
})
|
|
15
16
|
|
|
16
|
-
export async function bisectRun (client, { quilt, bad, good, run, marks = {}, paths = [''], api, log = () => {} }) {
|
|
17
|
+
export async function bisectRun (client, { quilt, bad, good, run, marks = {}, paths = [''], only, api, log = () => {} }) {
|
|
17
18
|
const m = { ...marks }
|
|
18
19
|
for (let i = 0; i < 200; i++) {
|
|
19
|
-
const r = await client.bisect(quilt, { bad, good, marks: m })
|
|
20
|
+
const r = await client.bisect(quilt, { bad, good, marks: m, ...(only && only.length ? { paths: only } : {}) })
|
|
20
21
|
if (r.done) return { ...r, marks: m }
|
|
21
22
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'seam-bisect-'))
|
|
22
23
|
try {
|