sealedrecord 0.2.0 → 0.4.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 CHANGED
@@ -1,45 +1,132 @@
1
1
  # sealedrecord
2
2
 
3
- Verify a sealed session record without trusting whoever produced it.
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
- A record is one JSON file: a chain of entries where every entry's SHA-256 digest commits to its full content and to the digest before it, an Ed25519 signature per enrolled entry, and optional time receipts. This library recomputes all of it and reports where, if anywhere, the chain breaks. It also matches an audio file to the entry that anchored it, by file bytes or, for uncompressed WAV, by sample bytes alone (a retagged copy still finds its record).
5
+ ## Verify in a browser
6
6
 
7
- WebCrypto only. No runtime dependencies. Same bytes in a browser and in Node 20 or later.
7
+ https://juanlentino.github.io/sealedrecord/ is this library served as static files: drop a record, get a reading. The page imports the published npm tarball of the version it names, is rebuilt only by the release workflow, and makes no network request after it loads. The example buttons use the conformance vectors below.
8
8
 
9
- ## Use
9
+ ## Install and verify
10
+
11
+ ```sh
12
+ npm install sealedrecord
13
+ ```
10
14
 
11
15
  ```js
12
- import { verifyPackage, verifyReceipts, hashFile, pcmHashFile, findAnchors, findPcmAnchors } from "sealedrecord";
16
+ import { readFileSync } from "node:fs";
17
+ import { verifyPackage, verifyReceipts } from "sealedrecord";
13
18
 
14
- const pkg = JSON.parse(text);
19
+ const pkg = JSON.parse(readFileSync("vectors/record.json", "utf8"));
15
20
  const reading = await verifyPackage(pkg);
16
- // reading.kind: "holds" | "unsealed" | "altered" | "malformed"
21
+ console.log(reading.kind, reading.signed, reading.entries.length);
22
+ // holds true 9
17
23
 
18
24
  const receipts = await verifyReceipts({ ...pkg, entries: reading.entries });
25
+ console.log(receipts);
26
+ // { total: 9, receipted: 9, verified: 9, problems: [] }
27
+ ```
28
+
29
+ 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:
19
30
 
20
- const { sha256 } = await hashFile(file);
21
- const exact = findAnchors(sha256, reading.entries);
22
- const byAudio = exact.length ? exact : findPcmAnchors(await pcmHashFile(file), reading.entries);
31
+ ```js
32
+ pkg.entries[3].note = "chorus wants a triple";
33
+ const broken = await verifyPackage(pkg);
34
+ console.log(broken.kind, broken.breakSeq);
35
+ // altered 4
36
+ console.log(broken.detail);
37
+ // entry 4 (note) does not match its recorded digest: recomputed c056c813…, recorded a3442bc7…; its content was altered after signing
38
+ console.log(broken.entries.length);
39
+ // 3 (the accepted prefix; nothing after the break is read)
23
40
  ```
24
41
 
25
- `docs/FORMAT.md` specifies the record format completely enough to write a second reader from the document alone. `vectors/` holds signed conformance records the test suite runs against; `npm run vectors` regenerates them with fresh keys.
42
+ `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).
43
+
44
+ Matching an audio file to the entry that anchored it:
45
+
46
+ ```js
47
+ import { hashFile, pcmHashFile, findAnchors, findPcmAnchors } from "sealedrecord";
48
+
49
+ const { sha256 } = await hashFile(file); // File or Blob
50
+ const exact = findAnchors(sha256, reading.entries); // same bytes
51
+ const sameAudio = exact.length ? exact : findPcmAnchors(await pcmHashFile(file), reading.entries);
52
+ // findPcmAnchors matches on the WAV sample data alone, so a retagged copy
53
+ // (new metadata chunks, same samples) still finds its entry.
54
+ ```
55
+
56
+ ## What this does not do
57
+
58
+ - 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.
59
+ - No authorship inference. A verified signature proves that the holder of a key signed an entry. Who holds the key is outside the format.
60
+ - No key management, custody, backup, or revocation. The record carries public keys only.
61
+ - No signing user interface, storage, transport, or network access. The library takes bytes and returns a reading.
62
+ - No verification of timestamp proofs (OpenTimestamps or similar). They may travel in the record; this library does not check them.
63
+ - No reading or writing of C2PA manifests.
64
+ - No sample anchor for compressed audio. Only uncompressed WAV (PCM and IEEE float) has one; other files match by file hash only.
65
+
66
+ ## Specification and background
67
+
68
+ 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.
69
+
70
+ The design the format implements was published before this library existed:
71
+
72
+ - Provenance Over Detection. SSRN 6402298. https://papers.ssrn.com/abstract=6402298
73
+ - Provenance as Substrate. SSRN 6730343. https://papers.ssrn.com/abstract=6730343
74
+ - Author ORCID: https://orcid.org/0009-0006-8151-5920
26
75
 
27
- ## Producing records
76
+ The papers argue for what the format commits to and why; the specification says how. Where they differ, the specification governs this implementation.
28
77
 
29
- The producer half (`buildEvents`, `sealEntry`, `buildPackage`) is included so a second implementation can check its output against a reference. Key custody, storage, and everything around the record are out of scope here.
78
+ ## Conformance vectors
30
79
 
31
- ## What this does not cover
80
+ `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.
32
81
 
33
- The library reads and produces the record. It deliberately stops there.
82
+ 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.
34
83
 
35
- - **Key custody.** Who holds a private key, how it is generated, backed up, or revoked, is the producer's problem. The record carries public keys only.
36
- - **Identity.** An actor id is an opaque string. The record proves that whoever holds a key signed an entry; binding that key to a person is outside the format.
37
- - **Timestamp proofs.** OpenTimestamps or similar proofs may ride in `attestations`; this library carries them and does not verify them. Use standard tooling.
38
- - **Content Credentials.** C2PA manifests embedded in audio files are neither read nor written here. The sample anchor survives them because they live in their own chunks.
39
- - **Transport and storage.** Nothing here fetches, uploads, or persists. Hand it bytes; it hands back a reading.
40
- - **Working documents.** Only sealed records are specified. An unsealed session in transit between collaborators is a different document with a different format tag, and this reader rejects it as `malformed` on purpose.
41
- - **Compressed audio.** The sample anchor is defined for uncompressed WAV only. Anything else has no sample anchor, honestly, and matches by file hash alone.
42
- - **Any user interface.** Readings are plain objects with human-readable `detail` strings; rendering them is the caller's job.
84
+ ## Runtime
85
+
86
+ - Zero runtime dependencies. One dev dependency (vitest).
87
+ - 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`.
88
+ - 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.
89
+ - `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.
90
+
91
+ ## API
92
+
93
+ Reading:
94
+
95
+ - `verifyPackage(pkg)`: recompute the chain and signatures; returns the reading described above.
96
+ - `verifyReceipts(pkg)`: verify time receipts against the key carried in `pkg.attestations`; returns counts and problems.
97
+ - `hashFile(file)`: `{ name, size, sha256 }` of a File or Blob.
98
+ - `pcmHash(arrayBuffer)`: sample anchor of a WAV, or `null` when the buffer is not one this reader understands.
99
+ - `pcmHashFile(file)`: the same from a File or Blob.
100
+ - `findAnchors(sha256, entries)`: entries whose artifact has this file hash.
101
+ - `findPcmAnchors(pcmSha256, entries)`: entries whose artifact has this sample anchor; empty for `null`.
102
+ - `verdictOf(entries, laneId)`: per-track verdict (`pending`, `unverified`, `broken`, `intact`).
103
+
104
+ Format constants and primitives, for anyone writing their own reader or producer:
105
+
106
+ - `PKG_FORMAT`: the format tag a record must carry.
107
+ - `GENESIS`: the 64-zero digest the chain starts from.
108
+ - `entryHash(prev, index, entry)`: the entry digest, exactly as the specification defines it.
109
+ - `hhmm(m)`: the display time derived from a session minute; the reader checks it.
110
+ - `receiptCanonical({ sessionId, seq, hash, receivedAt })`: the string a receipt signs.
111
+ - `MAX_ARTIFACT_BYTES`: the file-size limit `hashFile` enforces.
112
+
113
+ Producing, included as a reference so a second producer can be checked against it:
114
+
115
+ - `buildEvents(rawEvents, signerFor)`: order, sequence, digest, and sign a list of raw events.
116
+ - `sealEntry({ prev, seq, raw, privateKey })`: one entry onto an existing chain, identical to what `buildEvents` would produce.
117
+ - `buildPackage(lanes, entries, meta, sealedAt, signers, attestations, note)`: the record object.
118
+ - `packageText(...)`: the same, serialized with two-space indentation.
119
+
120
+ WebCrypto wrappers used throughout, exported so callers hash and sign the same way the reader verifies:
121
+
122
+ - `sha256Hex(string)`, `signText(privateKey, string)`, `verifyText(publicKey, hexSignature, string)`
123
+ - `generateSigningKey()`, `exportJwk(key)`, `importPublicJwk(jwk)`, `importPrivateJwk(jwk)`, `hasEd25519()`
124
+
125
+ ## Status and license
126
+
127
+ 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.
128
+
129
+ Apache-2.0. See [CHANGELOG.md](CHANGELOG.md) for what changed and when.
43
130
 
44
131
  ## Development
45
132
 
@@ -48,10 +135,4 @@ npm ci
48
135
  npm test
49
136
  ```
50
137
 
51
- Releases: bump the version and CHANGELOG, commit, push a `vX.Y.Z` tag. CI publishes to npm through trusted publishing; nothing is published by hand.
52
-
53
- To develop against a consuming app without editing its manifest: `npm link` here, then `npm link sealedrecord` in the app. A plain `npm install` there restores the published version.
54
-
55
- ## License
56
-
57
- Apache-2.0.
138
+ 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/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, fileMatchesArtifact, findAnchors, findPcmAnchors, formatBytes } from "./src/artifact.js";
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.2.0",
3
+ "version": "0.4.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. The file itself does
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
@@ -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 its time or content claim was altered`);
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/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 so a forger who rebuilds the hashes still
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 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 */
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 recomputed ${computed.slice(0, 8)}…, recorded ${e.hash.slice(0, 8)}…; its content was altered after signing` };
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)) {