sealedrecord 0.1.1 → 0.3.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 +108 -29
- package/docs/FORMAT.md +5 -5
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/artifact.js +2 -5
- package/src/attest.js +2 -2
- package/src/pkg.js +1 -1
- package/src/verify.js +6 -6
package/README.md
CHANGED
|
@@ -1,45 +1,128 @@
|
|
|
1
1
|
# sealedrecord
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Recomputes the hash chain, signatures, and time receipts of a sealed session record and reports whether it holds, and if not, at which entry it breaks.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Install and verify
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
```sh
|
|
8
|
+
npm install sealedrecord
|
|
9
|
+
```
|
|
10
10
|
|
|
11
11
|
```js
|
|
12
|
-
import {
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { verifyPackage, verifyReceipts } from "sealedrecord";
|
|
13
14
|
|
|
14
|
-
const pkg = JSON.parse(
|
|
15
|
+
const pkg = JSON.parse(readFileSync("vectors/record.json", "utf8"));
|
|
15
16
|
const reading = await verifyPackage(pkg);
|
|
16
|
-
|
|
17
|
+
console.log(reading.kind, reading.signed, reading.entries.length);
|
|
18
|
+
// holds true 9
|
|
17
19
|
|
|
18
20
|
const receipts = await verifyReceipts({ ...pkg, entries: reading.entries });
|
|
21
|
+
console.log(receipts);
|
|
22
|
+
// { total: 9, receipted: 9, verified: 9, problems: [] }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A record that has been changed after sealing breaks at the changed entry. The reading names the entry, what was recomputed, and what the file claimed:
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
pkg.entries[3].note = "chorus wants a triple";
|
|
29
|
+
const broken = await verifyPackage(pkg);
|
|
30
|
+
console.log(broken.kind, broken.breakSeq);
|
|
31
|
+
// altered 4
|
|
32
|
+
console.log(broken.detail);
|
|
33
|
+
// entry 4 (note) does not match its recorded digest: recomputed c056c813…, recorded a3442bc7…; its content was altered after signing
|
|
34
|
+
console.log(broken.entries.length);
|
|
35
|
+
// 3 (the accepted prefix; nothing after the break is read)
|
|
36
|
+
```
|
|
19
37
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
38
|
+
`reading.kind` is one of four values. `holds`: every digest and signature recomputes and the last entry seals the record. `unsealed`: the chain recomputes but was never sealed. `altered`: the chain fails at `breakSeq`. `malformed`: not a record this reader reads (wrong format tag, no entries, over the size limits).
|
|
39
|
+
|
|
40
|
+
Matching an audio file to the entry that anchored it:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { hashFile, pcmHashFile, findAnchors, findPcmAnchors } from "sealedrecord";
|
|
44
|
+
|
|
45
|
+
const { sha256 } = await hashFile(file); // File or Blob
|
|
46
|
+
const exact = findAnchors(sha256, reading.entries); // same bytes
|
|
47
|
+
const sameAudio = exact.length ? exact : findPcmAnchors(await pcmHashFile(file), reading.entries);
|
|
48
|
+
// findPcmAnchors matches on the WAV sample data alone, so a retagged copy
|
|
49
|
+
// (new metadata chunks, same samples) still finds its entry.
|
|
23
50
|
```
|
|
24
51
|
|
|
25
|
-
|
|
52
|
+
## What this does not do
|
|
53
|
+
|
|
54
|
+
- No audio analysis, fingerprinting, similarity, or detection of any kind. The sample anchor is a SHA-256 over raw PCM bytes; one sample different, no match.
|
|
55
|
+
- No authorship inference. A verified signature proves that the holder of a key signed an entry. Who holds the key is outside the format.
|
|
56
|
+
- No key management, custody, backup, or revocation. The record carries public keys only.
|
|
57
|
+
- No signing user interface, storage, transport, or network access. The library takes bytes and returns a reading.
|
|
58
|
+
- No verification of timestamp proofs (OpenTimestamps or similar). They may travel in the record; this library does not check them.
|
|
59
|
+
- No reading or writing of C2PA manifests.
|
|
60
|
+
- No sample anchor for compressed audio. Only uncompressed WAV (PCM and IEEE float) has one; other files match by file hash only.
|
|
61
|
+
|
|
62
|
+
## Specification and background
|
|
63
|
+
|
|
64
|
+
The normative specification is [docs/FORMAT.md](docs/FORMAT.md). It is written so that a second implementation can be built from the document alone: the digest preimage and its stringification rules, the check order, both anchors byte for byte, the receipt canonical form, limits, and the exact outcomes.
|
|
65
|
+
|
|
66
|
+
The design the format implements was published before this library existed:
|
|
26
67
|
|
|
27
|
-
|
|
68
|
+
- Provenance Over Detection. SSRN 6402298. https://papers.ssrn.com/abstract=6402298
|
|
69
|
+
- Provenance as Substrate. SSRN 6730343. https://papers.ssrn.com/abstract=6730343
|
|
70
|
+
- Author ORCID: https://orcid.org/0009-0006-8151-5920
|
|
28
71
|
|
|
29
|
-
The
|
|
72
|
+
The papers argue for what the format commits to and why; the specification says how. Where they differ, the specification governs this implementation.
|
|
30
73
|
|
|
31
|
-
##
|
|
74
|
+
## Conformance vectors
|
|
32
75
|
|
|
33
|
-
|
|
76
|
+
`vectors/record.json` is a signed record with three signers, nine entries, receipts on every entry, a derivation link, and anchored audio. `vectors/take.wav` matches entry 2 by file hash; `vectors/take-retagged.wav` has the same samples and an extra metadata chunk, so it matches entry 2 by sample anchor only. `test/vectors.test.js` runs against all three, including a tamper case that must fail at entry 4.
|
|
34
77
|
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
78
|
+
A third-party implementation can run against the same files. That is what makes the format independently implementable rather than defined by whatever this code happens to do. `npm run vectors` regenerates the set with fresh keys; the generator verifies its own output and refuses to write a record that does not hold.
|
|
79
|
+
|
|
80
|
+
## Runtime
|
|
81
|
+
|
|
82
|
+
- Zero runtime dependencies. One dev dependency (vitest).
|
|
83
|
+
- Requires WebCrypto with SHA-256 and Ed25519: Node 20 or later, or any browser whose `crypto.subtle` implements Ed25519. Where Ed25519 is missing, `hasEd25519()` returns false, `verifyPackage` checks the hash chain only, and the reading carries `signed: false`.
|
|
84
|
+
- Every computation is SHA-256 or Ed25519 over UTF-8 strings or raw bytes, with lowercase hex output, so results do not depend on the runtime. The test suite itself runs under Node.
|
|
85
|
+
- `hashFile` reads the whole file into memory and refuses files over 200 MiB (`MAX_ARTIFACT_BYTES`). That is a limit of this reader, not of the format.
|
|
86
|
+
|
|
87
|
+
## API
|
|
88
|
+
|
|
89
|
+
Reading:
|
|
90
|
+
|
|
91
|
+
- `verifyPackage(pkg)`: recompute the chain and signatures; returns the reading described above.
|
|
92
|
+
- `verifyReceipts(pkg)`: verify time receipts against the key carried in `pkg.attestations`; returns counts and problems.
|
|
93
|
+
- `hashFile(file)`: `{ name, size, sha256 }` of a File or Blob.
|
|
94
|
+
- `pcmHash(arrayBuffer)`: sample anchor of a WAV, or `null` when the buffer is not one this reader understands.
|
|
95
|
+
- `pcmHashFile(file)`: the same from a File or Blob.
|
|
96
|
+
- `findAnchors(sha256, entries)`: entries whose artifact has this file hash.
|
|
97
|
+
- `findPcmAnchors(pcmSha256, entries)`: entries whose artifact has this sample anchor; empty for `null`.
|
|
98
|
+
- `verdictOf(entries, laneId)`: per-track verdict (`pending`, `unverified`, `broken`, `intact`).
|
|
99
|
+
|
|
100
|
+
Format constants and primitives, for anyone writing their own reader or producer:
|
|
101
|
+
|
|
102
|
+
- `PKG_FORMAT`: the format tag a record must carry.
|
|
103
|
+
- `GENESIS`: the 64-zero digest the chain starts from.
|
|
104
|
+
- `entryHash(prev, index, entry)`: the entry digest, exactly as the specification defines it.
|
|
105
|
+
- `hhmm(m)`: the display time derived from a session minute; the reader checks it.
|
|
106
|
+
- `receiptCanonical({ sessionId, seq, hash, receivedAt })`: the string a receipt signs.
|
|
107
|
+
- `MAX_ARTIFACT_BYTES`: the file-size limit `hashFile` enforces.
|
|
108
|
+
|
|
109
|
+
Producing, included as a reference so a second producer can be checked against it:
|
|
110
|
+
|
|
111
|
+
- `buildEvents(rawEvents, signerFor)`: order, sequence, digest, and sign a list of raw events.
|
|
112
|
+
- `sealEntry({ prev, seq, raw, privateKey })`: one entry onto an existing chain, identical to what `buildEvents` would produce.
|
|
113
|
+
- `buildPackage(lanes, entries, meta, sealedAt, signers, attestations, note)`: the record object.
|
|
114
|
+
- `packageText(...)`: the same, serialized with two-space indentation.
|
|
115
|
+
|
|
116
|
+
WebCrypto wrappers used throughout, exported so callers hash and sign the same way the reader verifies:
|
|
117
|
+
|
|
118
|
+
- `sha256Hex(string)`, `signText(privateKey, string)`, `verifyText(publicKey, hexSignature, string)`
|
|
119
|
+
- `generateSigningKey()`, `exportJwk(key)`, `importPublicJwk(jwk)`, `importPrivateJwk(jwk)`, `hasEd25519()`
|
|
120
|
+
|
|
121
|
+
## Status and license
|
|
122
|
+
|
|
123
|
+
Version 0.x. The format tag is `sealedrecord/package.v3`; the digest rules have been stable across the tag's history, and the current constants are the ones intended to freeze. 1.0 will mean the format is frozen: any later change to a committed field, the preimage, or the check order gets a new tag, and this reader keeps reading v3.
|
|
124
|
+
|
|
125
|
+
Apache-2.0. See [CHANGELOG.md](CHANGELOG.md) for what changed and when.
|
|
43
126
|
|
|
44
127
|
## Development
|
|
45
128
|
|
|
@@ -48,8 +131,4 @@ npm ci
|
|
|
48
131
|
npm test
|
|
49
132
|
```
|
|
50
133
|
|
|
51
|
-
To develop against a consuming
|
|
52
|
-
|
|
53
|
-
## License
|
|
54
|
-
|
|
55
|
-
Apache-2.0.
|
|
134
|
+
Releases are a version bump, a CHANGELOG entry, and a `vX.Y.Z` tag; CI publishes to npm through trusted publishing. To develop against a consuming project without editing its manifest, `npm link` here and `npm link sealedrecord` there.
|
package/docs/FORMAT.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Sealed record format, version `
|
|
1
|
+
# Sealed record format, version `sealedrecord/package.v3`
|
|
2
2
|
|
|
3
3
|
This document specifies the record completely enough to implement an independent reader. Where it and the reference implementation disagree, the reference implementation is the bug report; the record bytes are the authority.
|
|
4
4
|
|
|
5
|
-
The format identifier `
|
|
5
|
+
The format identifier `sealedrecord/package.v3` and the receipt tag `sealedrecord/receipt.v1` are opaque protocol constants. Readers compare them byte for byte and attach no meaning to them. The version suffix counts revisions of the digest rules (v3 is the third), and the namespace is simply where this specification lives; a namespace was renamed once before any record left the room, which is why the suffix did not restart at v1.
|
|
6
6
|
|
|
7
7
|
## 1. Encoding
|
|
8
8
|
|
|
@@ -12,7 +12,7 @@ A record is one UTF-8 JSON document. All digests and signatures are lowercase he
|
|
|
12
12
|
|
|
13
13
|
| Field | Type | Committed | Notes |
|
|
14
14
|
|---|---|---|---|
|
|
15
|
-
| `format` | string | no | Must equal `
|
|
15
|
+
| `format` | string | no | Must equal `sealedrecord/package.v3`. Anything else is `malformed`. |
|
|
16
16
|
| `note` | string | no | Free text for humans. Readers ignore it. |
|
|
17
17
|
| `session` | object | partly | `id` (optional string), `code`, `title`. Only `id` participates in receipts (§7). |
|
|
18
18
|
| `sealedAt` | string | no | The `t` of the sealing entry. Display only. |
|
|
@@ -110,7 +110,7 @@ When the record carries no `signers`, or the runtime cannot do Ed25519, signatur
|
|
|
110
110
|
|
|
111
111
|
### 5.1 Malformed
|
|
112
112
|
|
|
113
|
-
Return `{ kind: "malformed", detail }` when: the value is not an object; `format` differs from `
|
|
113
|
+
Return `{ kind: "malformed", detail }` when: the value is not an object; `format` differs from `sealedrecord/package.v3`; `entries` is not a non-empty array; `entries.length > 10000`; `signers` has more than 200 keys.
|
|
114
114
|
|
|
115
115
|
### 5.2 Walk
|
|
116
116
|
|
|
@@ -184,7 +184,7 @@ A receipt for entry `seq` is valid when
|
|
|
184
184
|
|
|
185
185
|
```
|
|
186
186
|
Ed25519.verify( attestations.key, sig_bytes, UTF8(canonical) ) == true
|
|
187
|
-
canonical = "
|
|
187
|
+
canonical = "sealedrecord/receipt.v1|" + session.id + "|" + seq + "|" + hash + "|" + received_at
|
|
188
188
|
```
|
|
189
189
|
|
|
190
190
|
with `hash` taken from the entry (verify the chain first; receipts vouch for time, not content) and `session.id` stringified as in §4.1 (a missing id commits as `undefined`). Drop any JWK `alg` member before importing `attestations.key`.
|
package/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
export { verifyPackage } from "./src/verify.js";
|
|
5
5
|
export { verifyReceipts, receiptCanonical } from "./src/attest.js";
|
|
6
|
-
export { MAX_ARTIFACT_BYTES, hashFile,
|
|
6
|
+
export { MAX_ARTIFACT_BYTES, hashFile, findAnchors, findPcmAnchors } from "./src/artifact.js";
|
|
7
7
|
export { pcmHash, pcmHashFile } from "./src/pcm.js";
|
|
8
8
|
export { GENESIS, entryHash, hhmm, verdictOf, buildEvents } from "./src/chain.js";
|
|
9
9
|
export { PKG_FORMAT, buildPackage, packageText } from "./src/pkg.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sealedrecord",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Verify a sealed session record: recompute the SHA-256 chain, check Ed25519 entry signatures and time receipts, and match audio files to their anchors. WebCrypto only, browser and Node.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/artifact.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* Artifact anchoring: the file's SHA-256, computed wherever the reader
|
|
2
|
-
runs, becomes part of the entry the author signs.
|
|
2
|
+
runs, becomes part of the entry the author signs. formatBytes is exported
|
|
3
|
+
here for its own test and the cap message; it is not on the public surface. The file itself does
|
|
3
4
|
not travel with the record; the chain holds its identity, the producer
|
|
4
5
|
holds the bytes. */
|
|
5
6
|
/* WebCrypto digests a whole ArrayBuffer in memory — no streaming — so cap
|
|
@@ -22,10 +23,6 @@ export const formatBytes = (n) => {
|
|
|
22
23
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
23
24
|
};
|
|
24
25
|
|
|
25
|
-
/* Re-check a file someone hands you against an anchored entry. */
|
|
26
|
-
export const fileMatchesArtifact = async (file, artifact) =>
|
|
27
|
-
(await hashFile(file)).sha256 === artifact.sha256;
|
|
28
|
-
|
|
29
26
|
/* Every entry that anchors this exact file — the reader's answer to
|
|
30
27
|
"where does this file appear in the session?" */
|
|
31
28
|
export const findAnchors = (sha256, entries) =>
|
package/src/attest.js
CHANGED
|
@@ -10,7 +10,7 @@ import { importPublicJwk, verifyText } from "./crypto.js";
|
|
|
10
10
|
|
|
11
11
|
/* The signing side must byte-match this canonical form. */
|
|
12
12
|
export const receiptCanonical = ({ sessionId, seq, hash, receivedAt }) =>
|
|
13
|
-
`
|
|
13
|
+
`sealedrecord/receipt.v1|${sessionId}|${seq}|${hash}|${receivedAt}`;
|
|
14
14
|
|
|
15
15
|
export const verifyReceipts = async (pkg) => {
|
|
16
16
|
const entries = pkg.entries ?? [];
|
|
@@ -35,7 +35,7 @@ export const verifyReceipts = async (pkg) => {
|
|
|
35
35
|
sessionId: pkg.session?.id, seq: e.seq, hash: e.hash, receivedAt: r.received_at,
|
|
36
36
|
})));
|
|
37
37
|
if (ok) out.verified += 1;
|
|
38
|
-
else out.problems.push(`entry ${e.seq}: receipt does not verify
|
|
38
|
+
else out.problems.push(`entry ${e.seq}: receipt does not verify; its time or content claim was altered`);
|
|
39
39
|
}
|
|
40
40
|
return out;
|
|
41
41
|
};
|
package/src/pkg.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
/* v3: artifact commits gain the PCM secondary anchor, and entries commit
|
|
8
8
|
derivation links (derivedFrom) and the reserved signature-scheme field
|
|
9
9
|
(alg). v1 and v2 never shipped; no legacy records to honor. */
|
|
10
|
-
export const PKG_FORMAT = "
|
|
10
|
+
export const PKG_FORMAT = "sealedrecord/package.v3";
|
|
11
11
|
|
|
12
12
|
/* Free text for humans, never committed, never read by a verifier. */
|
|
13
13
|
export const DEFAULT_NOTE = "Sealed record: SHA-256 chain digests, Ed25519 entry signatures. Attestations (time receipts, timestamp proofs) are metadata about the chain, never part of it.";
|
package/src/verify.js
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
nothing else: no session state, no crew list, no trust in any field the
|
|
3
3
|
package asserts about itself. It recomputes the whole SHA-256 chain from
|
|
4
4
|
genesis, and where the package carries signers it verifies each enrolled
|
|
5
|
-
entry's Ed25519 signature
|
|
5
|
+
entry's Ed25519 signature, so a forger who rebuilds the hashes still
|
|
6
6
|
fails at the first entry they could not re-sign.
|
|
7
7
|
|
|
8
8
|
Result kinds:
|
|
9
|
-
malformed
|
|
10
|
-
altered
|
|
11
|
-
unsealed
|
|
12
|
-
holds
|
|
9
|
+
malformed: not a package this reader can read; `detail` says why
|
|
10
|
+
altered: the chain fails at `breakSeq`; `detail` names what failed
|
|
11
|
+
unsealed: chain recomputes but the package was never sealed
|
|
12
|
+
holds: chain recomputes end to end and the package is sealed */
|
|
13
13
|
|
|
14
14
|
import { GENESIS, entryHash, hhmm, verdictOf } from "./chain.js";
|
|
15
15
|
import { verifyText, importPublicJwk, hasEd25519 } from "./crypto.js";
|
|
@@ -85,7 +85,7 @@ export const verifyPackage = async (pkg) => {
|
|
|
85
85
|
}
|
|
86
86
|
const computed = await entryHash(prev, i, e);
|
|
87
87
|
if (computed !== e.hash) {
|
|
88
|
-
breakAt = { seq: i + 1, detail: `entry ${i + 1} (${e.action}) does not match its recorded digest
|
|
88
|
+
breakAt = { seq: i + 1, detail: `entry ${i + 1} (${e.action}) does not match its recorded digest: recomputed ${computed.slice(0, 8)}…, recorded ${e.hash.slice(0, 8)}…; its content was altered after signing` };
|
|
89
89
|
break;
|
|
90
90
|
}
|
|
91
91
|
if (e.t !== hhmm(e.m)) {
|