sealedrecord 0.1.1

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.

Potentially problematic release.


This version of sealedrecord might be problematic. Click here for more details.

package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # sealedrecord
2
+
3
+ Verify a sealed session record without trusting whoever produced it.
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).
6
+
7
+ WebCrypto only. No runtime dependencies. Same bytes in a browser and in Node 20 or later.
8
+
9
+ ## Use
10
+
11
+ ```js
12
+ import { verifyPackage, verifyReceipts, hashFile, pcmHashFile, findAnchors, findPcmAnchors } from "sealedrecord";
13
+
14
+ const pkg = JSON.parse(text);
15
+ const reading = await verifyPackage(pkg);
16
+ // reading.kind: "holds" | "unsealed" | "altered" | "malformed"
17
+
18
+ const receipts = await verifyReceipts({ ...pkg, entries: reading.entries });
19
+
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);
23
+ ```
24
+
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.
26
+
27
+ ## Producing records
28
+
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.
30
+
31
+ ## What this does not cover
32
+
33
+ The library reads and produces the record. It deliberately stops there.
34
+
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.
43
+
44
+ ## Development
45
+
46
+ ```sh
47
+ npm ci
48
+ npm test
49
+ ```
50
+
51
+ 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.
52
+
53
+ ## License
54
+
55
+ Apache-2.0.
package/docs/FORMAT.md ADDED
@@ -0,0 +1,210 @@
1
+ # Sealed record format, version `selo/package.v3`
2
+
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
+
5
+ The format identifier `selo/package.v3` and the receipt tag `selo-receipt.v1` are opaque protocol constants. Readers compare them byte for byte and attach no meaning to them.
6
+
7
+ ## 1. Encoding
8
+
9
+ A record is one UTF-8 JSON document. All digests and signatures are lowercase hexadecimal strings. All hashing and signing operates on the UTF-8 bytes of the strings described below, never on raw binary except where §6 says so.
10
+
11
+ ## 2. Top level
12
+
13
+ | Field | Type | Committed | Notes |
14
+ |---|---|---|---|
15
+ | `format` | string | no | Must equal `selo/package.v3`. Anything else is `malformed`. |
16
+ | `note` | string | no | Free text for humans. Readers ignore it. |
17
+ | `session` | object | partly | `id` (optional string), `code`, `title`. Only `id` participates in receipts (§7). |
18
+ | `sealedAt` | string | no | The `t` of the sealing entry. Display only. |
19
+ | `tracks` | array | no | `{ id, name, origin }`. Used only to compute per-track verdicts (§5.4). |
20
+ | `signers` | object | no | Map from actor id to an Ed25519 public JWK (`kty: "OKP"`, `crv: "Ed25519"`, `x`). Optional. |
21
+ | `attestations` | object | no | `{ key, receipts, ... }`, see §7. Optional. |
22
+ | `entries` | array | yes | The chain. 1 to 10000 entries. |
23
+
24
+ "Committed" means the value participates in an entry digest. Nothing at the top level is committed directly; the chain is committed entry by entry, and the reader believes nothing else in the file.
25
+
26
+ ## 3. Entry
27
+
28
+ | Field | Type | Required | Committed |
29
+ |---|---|---|---|
30
+ | `seq` | integer | yes | as the zero-based index `seq - 1` |
31
+ | `m` | number | yes | yes |
32
+ | `t` | string | yes | no, but must equal `hhmm(m)` (§4.3) |
33
+ | `room` | string | yes | yes |
34
+ | `lane` | string or null | emitted always | yes |
35
+ | `action` | string | yes | yes |
36
+ | `note` | string | no | yes (absent commits as empty) |
37
+ | `artifact` | object | no | yes (§4.2) |
38
+ | `derivedFrom` | integer | no | yes (absent commits as empty) |
39
+ | `alg` | string | no | yes (absent commits as empty) |
40
+ | `actor` | object | yes | `id` (string or null) and `name` both committed |
41
+ | `prev` | string | yes | yes, as the first preimage component |
42
+ | `hash` | string | yes | the digest; recomputed, never believed |
43
+ | `sig` | string | no | Ed25519 over `hash` (§4.4) |
44
+
45
+ The reference producer never emits `lane` as undefined; a reader recomputing the digest stringifies whatever it finds (§4.1), so a missing `lane` commits as the text `undefined` and will not match a digest produced with `null`.
46
+
47
+ ## 4. The chain
48
+
49
+ ### 4.1 Digest
50
+
51
+ ```
52
+ GENESIS = 64 ASCII zeros
53
+ prev_0 = GENESIS
54
+ hash_i = SHA-256( preimage_i ) as lowercase hex
55
+ prev_i = hash_{i-1}
56
+ ```
57
+
58
+ `preimage_i` is the following components joined with `|` (U+007C), no trailing separator, `i` being the zero-based index:
59
+
60
+ ```
61
+ prev | i | action | lane | actor.id | actor.name | m | room | note' | artifact' | derivedFrom' | alg'
62
+ ```
63
+
64
+ Stringification follows JavaScript template conversion: numbers in shortest decimal form, `null` as the text `null`, `undefined` as the text `undefined`, booleans as `true`/`false`. The primed components use these substitutions:
65
+
66
+ - `note'`: `note` if present, else empty string.
67
+ - `derivedFrom'`: `derivedFrom` if present, else empty string.
68
+ - `alg'`: `alg` if present, else empty string.
69
+ - `artifact'`: empty string when the entry has no artifact; otherwise `sha256:name:size:pcm'` where `pcm'` is `pcm_sha256` if present else empty. Note the `:` separator inside this component.
70
+ - `actor`: when absent, treated as `{ id: null, name: "unresolved" }`.
71
+
72
+ Because a pipe or colon inside a field value is not escaped, the format relies on the digest committing to the whole string, not on field boundaries being recoverable from it.
73
+
74
+ ### 4.2 Artifact
75
+
76
+ ```json
77
+ { "name": "take.wav", "size": 35244, "sha256": "<hex>", "pcm_sha256": "<hex>" }
78
+ ```
79
+
80
+ `sha256` is the SHA-256 of the file's bytes as delivered. `pcm_sha256` is the sample anchor of §6, present only for WAV files the anchor understands. `name` and `size` commit, so a renamed or padded file breaks the entry.
81
+
82
+ ### 4.3 Session clock
83
+
84
+ `m` is a session-relative minute count. `t` is derived from it and must match or the record is `altered`:
85
+
86
+ ```
87
+ hhmm(m) = pad2(14 + floor(m / 60)) + ":" + pad2(m mod 60)
88
+ ```
89
+
90
+ The 14 is a fixed downbeat, not a time zone. `t` is display; `m` is what is committed. `sealedAt` at the top level is the `t` of the last entry and is not checked.
91
+
92
+ ### 4.4 Signature
93
+
94
+ An entry whose `actor.id` is a non-empty string is enrolled. When the record carries `signers`, every enrolled entry must carry `sig`, and `signers[actor.id]` must be an importable Ed25519 public JWK, and
95
+
96
+ ```
97
+ Ed25519.verify( signers[actor.id], sig_bytes, UTF8(hash) ) == true
98
+ ```
99
+
100
+ `sig` is the 64-byte signature as 128 hex characters. The signed message is the UTF-8 bytes of the lowercase hex digest string, not the 32 raw digest bytes.
101
+
102
+ When the record carries no `signers`, or the runtime cannot do Ed25519, signatures are not checked and the reading reports `signed: false`. An unimportable key in `signers` fails every entry by that actor. Reader implementations may need to drop a JWK `alg` member before import; the curve field is authoritative.
103
+
104
+ ### 4.5 Derivation and scheme fields
105
+
106
+ - `derivedFrom`, when present, must be an integer `1 <= derivedFrom < seq`. It names an earlier entry by `seq`.
107
+ - `alg`, when present, must be `"Ed25519"`. Any other value is `altered` at that entry, since this reader cannot verify it.
108
+
109
+ ## 5. Reading a record
110
+
111
+ ### 5.1 Malformed
112
+
113
+ Return `{ kind: "malformed", detail }` when: the value is not an object; `format` differs from `selo/package.v3`; `entries` is not a non-empty array; `entries.length > 10000`; `signers` has more than 200 keys.
114
+
115
+ ### 5.2 Walk
116
+
117
+ Walk `entries` in order with `prev = GENESIS`. Stop at the first entry that fails any check, in this order, and report `{ kind: "altered", breakSeq, detail }` where `breakSeq` is the one-based position:
118
+
119
+ 1. Each of `seq`, `m`, `room`, `action`, `actor`, `prev`, `hash` is present (not undefined).
120
+ 2. `actor` is a non-null object.
121
+ 3. `seq == index + 1`.
122
+ 4. `prev == running prev`.
123
+ 5. `derivedFrom`, if present, is an integer in `[1, index]`.
124
+ 6. `alg`, if present, is `"Ed25519"`.
125
+ 7. Recomputed digest (§4.1) equals `hash`.
126
+ 8. `t == hhmm(m)`.
127
+ 9. If signatures are being checked and `actor.id` is truthy: `sig` present, key present and importable, signature verifies.
128
+
129
+ After each accepted entry, set `running prev = hash` and mark the entry `sealed = Boolean(actor.id)`.
130
+
131
+ Entries after the break are not read. `entries` in the result holds the accepted prefix.
132
+
133
+ ### 5.3 Outcome
134
+
135
+ If no entry failed: the record is `unsealed` unless the last entry's `action` is exactly `package sealed`, in which case it `holds`.
136
+
137
+ The result carries `session`, `sealedAt`, `entries` (accepted, with `sealed` added), `tracks`, `verdicts`, `total` (count in the file), and `signed` (whether signatures were checked).
138
+
139
+ ### 5.4 Track verdicts
140
+
141
+ For each `tracks[i]`, take the accepted entries whose `lane` equals `tracks[i].id` (strict equality; the producer uses the lane string itself as the id). Then:
142
+
143
+ - no entries: `pending`
144
+ - first entry not sealed: `unverified` (origin unverified)
145
+ - any later entry not sealed: `broken`, with `gap` naming the first such entry
146
+ - otherwise: `intact`
147
+
148
+ ## 6. Anchors
149
+
150
+ ### 6.1 File anchor
151
+
152
+ `sha256` = SHA-256 over the entire file, lowercase hex. The reference reader refuses files over 200 MiB because it digests in memory; that is a reader limit, not a format rule.
153
+
154
+ ### 6.2 Sample anchor (`pcm_sha256`)
155
+
156
+ Defined only for RIFF/WAVE files with an uncompressed `fmt ` chunk. Parse the container yourself; do not decode through an audio API.
157
+
158
+ 1. Bytes 0..3 are `RIFF`, bytes 8..11 are `WAVE`, file is at least 12 bytes. Otherwise: no anchor.
159
+ 2. From offset 12, read chunks: 4-byte id, 4-byte little-endian size, body. If `body + size` exceeds the file: no anchor. Advance by `8 + size + (size mod 2)`; odd bodies are padded.
160
+ 3. `fmt ` chunk: size at least 16; format tag (u16 LE at body+0) is 1 (PCM) or 3 (IEEE float), otherwise no anchor. Read `channels` (u16 LE at body+2), `rate` (u32 LE at body+4), `bits` (u16 LE at body+14).
161
+ 4. `data` chunk: the body bytes, exactly `size` long.
162
+ 5. If more than one `fmt ` or `data` chunk appears, the last one read wins.
163
+ 6. Both chunks required, else no anchor.
164
+ 7. Anchor = SHA-256 over `UTF8("pcm|" + channels + "|" + rate + "|" + bits + "|")` followed by the `data` body bytes, lowercase hex.
165
+
166
+ Any parse failure yields no anchor (`null`), never an error. Metadata chunks (`LIST`, `bext`, `id3 `) do not participate, so retagging leaves the anchor unchanged; a transcode or resample changes it.
167
+
168
+ ### 6.3 Matching
169
+
170
+ `findAnchors(sha256, entries)` returns every entry whose `artifact.sha256` equals the file hash. `findPcmAnchors(pcm, entries)` returns every entry whose `artifact.pcm_sha256` equals the sample anchor, and returns nothing for a null anchor. A reader shows an exact-bytes match and a samples-only match differently; the second means the delivered file is not the anchored file, only the same audio.
171
+
172
+ ## 7. Time receipts
173
+
174
+ `attestations` is metadata about the chain, never part of it. A record with none still verifies.
175
+
176
+ ```json
177
+ "attestations": {
178
+ "key": { "kty": "OKP", "crv": "Ed25519", "x": "..." },
179
+ "receipts": [ { "seq": 1, "received_at": "2026-08-14T18:05:00.000Z", "sig": "<hex>" } ]
180
+ }
181
+ ```
182
+
183
+ A receipt for entry `seq` is valid when
184
+
185
+ ```
186
+ Ed25519.verify( attestations.key, sig_bytes, UTF8(canonical) ) == true
187
+ canonical = "selo-receipt.v1|" + session.id + "|" + seq + "|" + hash + "|" + received_at
188
+ ```
189
+
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`.
191
+
192
+ The reader reports `total` entries, `receipted` (entries with a receipt), `verified`, and a `problems` list. Entries without a receipt are not a problem. An unimportable key is one problem and no receipts verify.
193
+
194
+ What a receipt proves: the holder of `attestations.key` saw this hash at this time. It is as strong as trust in that key and its clock, no stronger. Other attestation members (for example OpenTimestamps proofs) may ride alongside; this specification does not verify them.
195
+
196
+ ## 8. Producing a record
197
+
198
+ A conforming producer sorts raw events by `m` ascending, assigns `seq` from 1, derives `t`, computes each digest per §4.1, signs each enrolled entry's digest with that actor's private key, and serialises exactly the fields listed in §2 and §3, omitting optional fields that are absent rather than emitting `null`. The last entry of a finished record has `action` equal to `package sealed`.
199
+
200
+ ## 9. Limits and refusals
201
+
202
+ | Limit | Value | Result |
203
+ |---|---|---|
204
+ | entries | 1..10000 | `malformed` outside |
205
+ | signers | at most 200 | `malformed` above |
206
+ | file anchor input | reader policy, 200 MiB in the reference | error, not a reading |
207
+
208
+ ## 10. Test vectors
209
+
210
+ `vectors/` in this repository holds a signed record that `holds`, a WAV that matches an entry by file hash, and a retagged copy of the same WAV that matches only by sample anchor. `npm run vectors` regenerates them with fresh keys; the digests change, the structure does not.
package/index.js ADDED
@@ -0,0 +1,14 @@
1
+ /* The public surface. Reading first; the producer half follows so a second
2
+ implementation can check its output against a reference. */
3
+
4
+ export { verifyPackage } from "./src/verify.js";
5
+ export { verifyReceipts, receiptCanonical } from "./src/attest.js";
6
+ export { MAX_ARTIFACT_BYTES, hashFile, fileMatchesArtifact, findAnchors, findPcmAnchors, formatBytes } from "./src/artifact.js";
7
+ export { pcmHash, pcmHashFile } from "./src/pcm.js";
8
+ export { GENESIS, entryHash, hhmm, verdictOf, buildEvents } from "./src/chain.js";
9
+ export { PKG_FORMAT, buildPackage, packageText } from "./src/pkg.js";
10
+ export { sealEntry } from "./src/append.js";
11
+ export {
12
+ sha256Hex, generateSigningKey, exportJwk, importPublicJwk, importPrivateJwk,
13
+ signText, verifyText, hasEd25519,
14
+ } from "./src/crypto.js";
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "sealedrecord",
3
+ "version": "0.1.1",
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
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "src/",
14
+ "docs/FORMAT.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "scripts": {
20
+ "test": "vitest run",
21
+ "vectors": "node scripts/build-vectors.mjs"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/juanlentino/sealedrecord.git"
26
+ },
27
+ "keywords": [
28
+ "provenance",
29
+ "hash-chain",
30
+ "ed25519",
31
+ "sha-256",
32
+ "webcrypto",
33
+ "verification",
34
+ "audio"
35
+ ],
36
+ "devDependencies": {
37
+ "vitest": "^4.1.11"
38
+ }
39
+ }
package/src/append.js ADDED
@@ -0,0 +1,17 @@
1
+ /* Chain continuation: seal one new entry onto a live chain without
2
+ rebuilding from genesis. Produces exactly what buildEvents would have —
3
+ the reader cannot tell a continued chain from a rebuilt one, which the
4
+ round-trip test proves. */
5
+
6
+ import { entryHash, hhmm } from "./chain.js";
7
+ import { signText } from "./crypto.js";
8
+
9
+ export const sealEntry = async ({ prev, seq, raw, privateKey = null }) => {
10
+ const actor = raw.actor || { id: null, name: "unresolved" };
11
+ const sealed = Boolean(actor.id);
12
+ const base = { ...raw, actor, seq, t: hhmm(raw.m ?? 0), sealed, prev };
13
+ const hash = await entryHash(prev, seq - 1, base);
14
+ let sig;
15
+ if (sealed && privateKey) sig = await signText(privateKey, hash);
16
+ return { ...base, hash, ...(sig ? { sig } : {}) };
17
+ };
@@ -0,0 +1,38 @@
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
3
+ not travel with the record; the chain holds its identity, the producer
4
+ holds the bytes. */
5
+ /* WebCrypto digests a whole ArrayBuffer in memory — no streaming — so cap
6
+ loudly rather than letting a 2GB session bounce freeze the tab. */
7
+ export const MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
8
+
9
+ const HEX = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
10
+
11
+ export const hashFile = async (file) => {
12
+ if (file.size > MAX_ARTIFACT_BYTES) {
13
+ throw new Error(`file too large to anchor (${formatBytes(file.size)}; the cap is ${formatBytes(MAX_ARTIFACT_BYTES)})`);
14
+ }
15
+ const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
16
+ return { name: file.name, size: file.size, sha256: HEX(digest) };
17
+ };
18
+
19
+ export const formatBytes = (n) => {
20
+ if (n < 1024) return `${n} B`;
21
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
22
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
23
+ };
24
+
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
+ /* Every entry that anchors this exact file — the reader's answer to
30
+ "where does this file appear in the session?" */
31
+ export const findAnchors = (sha256, entries) =>
32
+ entries.filter((e) => e.artifact?.sha256 === sha256);
33
+
34
+ /* The survivability half of the same question: entries whose anchored
35
+ AUDIO matches, even though the file's bytes no longer do — a retagged
36
+ copy finds its record here. Null/absent hashes match nothing. */
37
+ export const findPcmAnchors = (pcmSha256, entries) =>
38
+ pcmSha256 ? entries.filter((e) => e.artifact?.pcm_sha256 === pcmSha256) : [];
package/src/attest.js ADDED
@@ -0,0 +1,41 @@
1
+ /* The reader's side of time attestation. Receipts are a server's signed
2
+ statement "I received the entry with this hash at this time", verifiable
3
+ against the attestation key the record carries, but only as strong as
4
+ trust in that server's clock and key custody. Timestamp proofs
5
+ (OpenTimestamps) are the trustless layer; they travel in the record for
6
+ standard tooling and are not verified here. Attestations are metadata
7
+ ABOUT the chain, never part of it: a record with none still verifies. */
8
+
9
+ import { importPublicJwk, verifyText } from "./crypto.js";
10
+
11
+ /* The signing side must byte-match this canonical form. */
12
+ export const receiptCanonical = ({ sessionId, seq, hash, receivedAt }) =>
13
+ `selo-receipt.v1|${sessionId}|${seq}|${hash}|${receivedAt}`;
14
+
15
+ export const verifyReceipts = async (pkg) => {
16
+ const entries = pkg.entries ?? [];
17
+ const out = { total: entries.length, receipted: 0, verified: 0, problems: [] };
18
+ const att = pkg.attestations;
19
+ if (!att?.key || !Array.isArray(att.receipts)) return out;
20
+
21
+ let key;
22
+ try {
23
+ key = await importPublicJwk({ ...att.key, alg: undefined });
24
+ } catch {
25
+ out.problems.push("the attestation key in this package is not importable");
26
+ return out;
27
+ }
28
+
29
+ const bySeq = new Map(att.receipts.map((r) => [r.seq, r]));
30
+ for (const e of entries) {
31
+ const r = bySeq.get(e.seq);
32
+ if (!r) continue;
33
+ out.receipted += 1;
34
+ const ok = r.sig && (await verifyText(key, r.sig, receiptCanonical({
35
+ sessionId: pkg.session?.id, seq: e.seq, hash: e.hash, receivedAt: r.received_at,
36
+ })));
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`);
39
+ }
40
+ return out;
41
+ };
package/src/chain.js ADDED
@@ -0,0 +1,68 @@
1
+ /* Chain projections. The event log is the single source of truth; everything
2
+ here derives from it and holds no state of its own.
3
+
4
+ Every entry's digest is SHA-256 over its full content plus its
5
+ predecessor's digest, and an enrolled actor's entry is signed Ed25519
6
+ over that digest. Identity custody lives outside the record. */
7
+
8
+ import { sha256Hex, signText } from "./crypto.js";
9
+
10
+ export const GENESIS = "0".repeat(64);
11
+ /* Canonical entry digest (format v3). Every content field is committed, so
12
+ altering any of them after the fact breaks the chain at exactly that
13
+ entry, including the anchored artifact: its file hash, name, size, and
14
+ (when the audio is uncompressed) its PCM hash all commit, so neither
15
+ the file, its name, nor the samples themselves can be swapped. v3 also
16
+ commits the derivation link (`derivedFrom`, an earlier entry's seq) and
17
+ the reserved signature-scheme field (`alg`; absent means Ed25519).
18
+ The reader recomputes this same function over a package it is handed.
19
+ v2 never shipped; clean break,
20
+ same as v1→v2. */
21
+ export const entryHash = (prev, i, e) => {
22
+ const actor = e.actor || { id: null, name: "unresolved" };
23
+ const artifact = e.artifact
24
+ ? `${e.artifact.sha256}:${e.artifact.name}:${e.artifact.size}:${e.artifact.pcm_sha256 ?? ""}`
25
+ : "";
26
+ return sha256Hex(
27
+ `${prev}|${i}|${e.action}|${e.lane}|${actor.id}|${actor.name}|${e.m}|${e.room}|${e.note ?? ""}|${artifact}|${e.derivedFrom ?? ""}|${e.alg ?? ""}`,
28
+ );
29
+ };
30
+
31
+ /* Session-relative clock from a 14:00 downbeat. Skeleton-stage: positions
32
+ inside one session. Real wall-clock timestamps arrive with persistence. */
33
+ export const hhmm = (m) =>
34
+ `${String(14 + Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
35
+
36
+ /* Adds seq, t, sealed, prev, hash, and — when the actor has a signing key —
37
+ an Ed25519 signature over the entry's digest. Derives defensively: events
38
+ are sorted by session time first, and an actor without an identifier is a
39
+ recorded gap, never an error. Sequential by construction: each digest
40
+ commits to the one before it. */
41
+ export const buildEvents = async (rawEvents, signerFor = null) => {
42
+ const ordered = [...rawEvents].sort((a, b) => (a.m ?? 0) - (b.m ?? 0));
43
+ let prev = GENESIS;
44
+ const out = [];
45
+ for (let i = 0; i < ordered.length; i++) {
46
+ const e = ordered[i];
47
+ const actor = e.actor || { id: null, name: "unresolved" };
48
+ const sealed = Boolean(actor.id);
49
+ const hash = await entryHash(prev, i, { ...e, actor });
50
+ let sig;
51
+ if (sealed && signerFor) {
52
+ const key = signerFor(actor.id);
53
+ if (key) sig = await signText(key, hash);
54
+ }
55
+ out.push({ ...e, actor, seq: i + 1, t: hhmm(e.m ?? 0), sealed, prev, hash, ...(sig ? { sig } : {}) });
56
+ prev = hash;
57
+ }
58
+ return out;
59
+ };
60
+
61
+ export const verdictOf = (events, laneId) => {
62
+ const own = events.filter((e) => e.lane === laneId);
63
+ if (!own.length) return { key: "pending", label: "no events" };
64
+ if (!own[0].sealed) return { key: "unverified", label: "origin unverified" };
65
+ const gap = own.find((e) => !e.sealed);
66
+ if (gap) return { key: "broken", label: "chain broken", gap };
67
+ return { key: "intact", label: "chain intact" };
68
+ };
package/src/crypto.js ADDED
@@ -0,0 +1,45 @@
1
+ /* SHA-256 for entry digests, Ed25519 for per-actor signatures, via
2
+ WebCrypto. Signing happens on the producer's side, always; a store that
3
+ holds chains can never forge them. Keys are generated extractable so a
4
+ caller can persist them. On a runtime without Ed25519 the chain runs
5
+ hash-only and records carry no signers. */
6
+ const HEX = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
7
+ const ENC = new TextEncoder();
8
+ const ED = { name: "Ed25519" };
9
+
10
+ export const sha256Hex = async (s) =>
11
+ HEX(await crypto.subtle.digest("SHA-256", ENC.encode(s)));
12
+
13
+ export const generateSigningKey = () =>
14
+ crypto.subtle.generateKey(ED, true, ["sign", "verify"]);
15
+
16
+ export const exportJwk = (key) => crypto.subtle.exportKey("jwk", key);
17
+
18
+ export const importPrivateJwk = (jwk) =>
19
+ crypto.subtle.importKey("jwk", jwk, ED, true, ["sign"]);
20
+
21
+ export const importPublicJwk = (jwk) =>
22
+ crypto.subtle.importKey("jwk", jwk, ED, true, ["verify"]);
23
+
24
+ export const signText = async (privateKey, s) =>
25
+ HEX(await crypto.subtle.sign(ED, privateKey, ENC.encode(s)));
26
+
27
+ export const verifyText = async (publicKey, signatureHex, s) => {
28
+ const bytes = signatureHex.match(/.{2}/g);
29
+ if (!bytes) return false;
30
+ const sig = new Uint8Array(bytes.map((h) => parseInt(h, 16)));
31
+ return crypto.subtle.verify(ED, publicKey, sig, ENC.encode(s));
32
+ };
33
+
34
+ let edSupport = null;
35
+ export const hasEd25519 = async () => {
36
+ if (edSupport === null) {
37
+ try {
38
+ await generateSigningKey();
39
+ edSupport = true;
40
+ } catch {
41
+ edSupport = false;
42
+ }
43
+ }
44
+ return edSupport;
45
+ };
package/src/pcm.js ADDED
@@ -0,0 +1,54 @@
1
+ /* The PCM secondary anchor: a hash of the audio's sample data plus the
2
+ parameters needed to play it — not the file's bytes. Retagging a WAV
3
+ (cover art, INFO chunks, bext) changes the file hash; it cannot change
4
+ this one. Deterministic by construction: we parse the container
5
+ ourselves, byte for byte — no Web Audio, no decoder variance — so the
6
+ same recording hashes the same in every browser. Uncompressed WAV
7
+ (PCM and IEEE float) only in phase 1; anything else is null, honestly. */
8
+
9
+ const HEX = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
10
+
11
+ const fourcc = (view, at) =>
12
+ String.fromCharCode(view.getUint8(at), view.getUint8(at + 1), view.getUint8(at + 2), view.getUint8(at + 3));
13
+
14
+ /* Returns the hex SHA-256 of "pcm|channels|rate|bits|" + data bytes, or
15
+ null when the buffer is not a WAV this phase understands. */
16
+ export const pcmHash = async (buffer) => {
17
+ try {
18
+ const view = new DataView(buffer);
19
+ if (view.byteLength < 12 || fourcc(view, 0) !== "RIFF" || fourcc(view, 8) !== "WAVE") return null;
20
+
21
+ let fmt = null;
22
+ let data = null;
23
+ let at = 12;
24
+ while (at + 8 <= view.byteLength) {
25
+ const id = fourcc(view, at);
26
+ const size = view.getUint32(at + 4, true);
27
+ const body = at + 8;
28
+ if (body + size > view.byteLength) return null; /* truncated chunk */
29
+ if (id === "fmt ") {
30
+ if (size < 16) return null;
31
+ const format = view.getUint16(body, true);
32
+ if (format !== 1 && format !== 3) return null; /* PCM & IEEE float only, for now */
33
+ fmt = {
34
+ channels: view.getUint16(body + 2, true),
35
+ rate: view.getUint32(body + 4, true),
36
+ bits: view.getUint16(body + 14, true),
37
+ };
38
+ }
39
+ if (id === "data") data = new Uint8Array(buffer, body, size);
40
+ at = body + size + (size % 2); /* RIFF pads odd chunks */
41
+ }
42
+ if (!fmt || !data) return null;
43
+
44
+ const prefix = new TextEncoder().encode(`pcm|${fmt.channels}|${fmt.rate}|${fmt.bits}|`);
45
+ const material = new Uint8Array(prefix.length + data.length);
46
+ material.set(prefix, 0);
47
+ material.set(data, prefix.length);
48
+ return HEX(await crypto.subtle.digest("SHA-256", material));
49
+ } catch {
50
+ return null;
51
+ }
52
+ };
53
+
54
+ export const pcmHashFile = async (file) => pcmHash(await file.arrayBuffer());
package/src/pkg.js ADDED
@@ -0,0 +1,41 @@
1
+ /* Sealed-record serialization. The record is what leaves the room: the
2
+ sealed prefix of the log, the track labels, the signers' public keys,
3
+ and nothing else. Derived fields (sealed, verdicts) are deliberately
4
+ absent; the reader re-derives them and takes nothing in the file at its
5
+ word. */
6
+
7
+ /* v3: artifact commits gain the PCM secondary anchor, and entries commit
8
+ derivation links (derivedFrom) and the reserved signature-scheme field
9
+ (alg). v1 and v2 never shipped; no legacy records to honor. */
10
+ export const PKG_FORMAT = "selo/package.v3";
11
+
12
+ /* Free text for humans, never committed, never read by a verifier. */
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.";
14
+
15
+ export const buildPackage = (lanes, sealedEvents, meta, sealedAt, signers = null, attestations = null, note = DEFAULT_NOTE) => ({
16
+ format: PKG_FORMAT,
17
+ note,
18
+ session: { ...(meta.id ? { id: meta.id } : {}), code: meta.code, title: meta.title },
19
+ sealedAt,
20
+ tracks: lanes.map((l) => ({ id: l.id, name: l.name, origin: l.origin })),
21
+ ...(signers && Object.keys(signers).length ? { signers } : {}),
22
+ ...(attestations ? { attestations } : {}),
23
+ entries: sealedEvents.map((e) => ({
24
+ seq: e.seq, m: e.m, t: e.t, room: e.room, lane: e.lane, action: e.action,
25
+ ...(e.note ? { note: e.note } : {}),
26
+ ...(e.artifact ? {
27
+ artifact: {
28
+ name: e.artifact.name, size: e.artifact.size, sha256: e.artifact.sha256,
29
+ ...(e.artifact.pcm_sha256 ? { pcm_sha256: e.artifact.pcm_sha256 } : {}),
30
+ },
31
+ } : {}),
32
+ ...(e.derivedFrom ? { derivedFrom: e.derivedFrom } : {}),
33
+ ...(e.alg ? { alg: e.alg } : {}),
34
+ actor: { id: e.actor.id, name: e.actor.name },
35
+ prev: e.prev, hash: e.hash,
36
+ ...(e.sig ? { sig: e.sig } : {}),
37
+ })),
38
+ });
39
+
40
+ export const packageText = (lanes, sealedEvents, meta, sealedAt, signers = null, attestations = null, note = DEFAULT_NOTE) =>
41
+ JSON.stringify(buildPackage(lanes, sealedEvents, meta, sealedAt, signers, attestations, note), null, 2);
package/src/verify.js ADDED
@@ -0,0 +1,125 @@
1
+ /* The independent reader's logic. It is handed a parsed package object and
2
+ nothing else: no session state, no crew list, no trust in any field the
3
+ package asserts about itself. It recomputes the whole SHA-256 chain from
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
6
+ fails at the first entry they could not re-sign.
7
+
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 */
13
+
14
+ import { GENESIS, entryHash, hhmm, verdictOf } from "./chain.js";
15
+ import { verifyText, importPublicJwk, hasEd25519 } from "./crypto.js";
16
+ import { PKG_FORMAT } from "./pkg.js";
17
+
18
+ const FIELDS = ["seq", "m", "room", "action", "actor", "prev", "hash"];
19
+ const MAX_ENTRIES = 10000;
20
+ const MAX_SIGNERS = 200; /* a session has a handful of members; this is hostile input */
21
+
22
+ const malformed = (detail) => ({ kind: "malformed", detail });
23
+
24
+ export const verifyPackage = async (pkg) => {
25
+ if (!pkg || typeof pkg !== "object") return malformed("not a JSON object");
26
+ if (pkg.format !== PKG_FORMAT) {
27
+ return malformed(`unknown format ${JSON.stringify(pkg.format ?? null)}; this reader reads ${PKG_FORMAT}`);
28
+ }
29
+ if (!Array.isArray(pkg.entries) || pkg.entries.length === 0) {
30
+ return malformed("the package carries no entries");
31
+ }
32
+ if (pkg.entries.length > MAX_ENTRIES) {
33
+ return malformed(`the package carries more than ${MAX_ENTRIES} entries; refusing to process it`);
34
+ }
35
+
36
+ /* Import signer keys, if the package carries any and the runtime can.
37
+ Null prototype: signer ids are attacker-controlled strings. */
38
+ const keys = Object.create(null);
39
+ const signers = pkg.signers && typeof pkg.signers === "object" ? pkg.signers : null;
40
+ if (signers && Object.keys(signers).length > MAX_SIGNERS) {
41
+ return malformed(`the package carries more than ${MAX_SIGNERS} signer keys; refusing to process it`);
42
+ }
43
+ const checkSigs = Boolean(signers) && (await hasEd25519());
44
+ if (checkSigs) {
45
+ for (const [id, jwk] of Object.entries(signers)) {
46
+ try {
47
+ keys[id] = await importPublicJwk(jwk);
48
+ } catch { /* an unimportable key simply fails that signer's entries below */ }
49
+ }
50
+ }
51
+
52
+ /* Re-derive what the session derived; trust nothing carried. */
53
+ const entries = [];
54
+ let prev = GENESIS;
55
+ let breakAt = null;
56
+ for (let i = 0; i < pkg.entries.length; i++) {
57
+ const e = pkg.entries[i];
58
+ const missing = FIELDS.find((f) => e?.[f] === undefined);
59
+ if (missing !== undefined) {
60
+ breakAt = { seq: i + 1, detail: `entry ${i + 1} is missing its ${missing} field` };
61
+ break;
62
+ }
63
+ if (e.actor === null || typeof e.actor !== "object") {
64
+ breakAt = { seq: i + 1, detail: `entry ${i + 1} carries no actor; every entry names who acted` };
65
+ break;
66
+ }
67
+ if (e.seq !== i + 1) {
68
+ breakAt = { seq: i + 1, detail: `entry ${i + 1} carries sequence number ${e.seq}; entries have been reordered or removed` };
69
+ break;
70
+ }
71
+ if (e.prev !== prev) {
72
+ breakAt = { seq: i + 1, detail: `entry ${i + 1} chains from ${e.prev.slice(0, 8)}…, but the preceding entry resolves to ${prev.slice(0, 8)}…; the chain does not connect here` };
73
+ break;
74
+ }
75
+ /* v3: a derivation link may only point at an earlier entry. */
76
+ if (e.derivedFrom !== undefined
77
+ && (!Number.isInteger(e.derivedFrom) || e.derivedFrom < 1 || e.derivedFrom > i)) {
78
+ breakAt = { seq: i + 1, detail: `entry ${i + 1} claims derivation from entry ${e.derivedFrom}, which is not an earlier entry in this chain` };
79
+ break;
80
+ }
81
+ /* v3: the signature-scheme field is reserved; this reader speaks Ed25519. */
82
+ if (e.alg !== undefined && e.alg !== "Ed25519") {
83
+ breakAt = { seq: i + 1, detail: `entry ${i + 1} is signed with ${e.alg}, a scheme this reader does not support yet` };
84
+ break;
85
+ }
86
+ const computed = await entryHash(prev, i, e);
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` };
89
+ break;
90
+ }
91
+ if (e.t !== hhmm(e.m)) {
92
+ breakAt = { seq: i + 1, detail: `entry ${i + 1} displays time ${e.t} but its committed position resolves to ${hhmm(e.m)}; the displayed time was altered` };
93
+ break;
94
+ }
95
+ if (checkSigs && e.actor?.id) {
96
+ const key = keys[e.actor.id];
97
+ if (!e.sig || !key) {
98
+ breakAt = { seq: i + 1, detail: `entry ${i + 1} carries the identifier ${e.actor.id} but no verifiable signature; an enrolled entry must be signed` };
99
+ break;
100
+ }
101
+ if (!(await verifyText(key, e.sig, e.hash))) {
102
+ breakAt = { seq: i + 1, detail: `entry ${i + 1} (${e.action}) fails signature verification: the chain was rebuilt without ${e.actor.name}'s key` };
103
+ break;
104
+ }
105
+ }
106
+ entries.push({ ...e, sealed: Boolean(e.actor.id) });
107
+ prev = e.hash;
108
+ }
109
+
110
+ const tracks = Array.isArray(pkg.tracks) ? pkg.tracks : [];
111
+ const verdicts = tracks.map((tr) => ({
112
+ track: tr, verdict: verdictOf(entries, tr.id),
113
+ }));
114
+ const base = {
115
+ session: pkg.session ?? {}, sealedAt: pkg.sealedAt ?? null,
116
+ entries, tracks, verdicts, total: pkg.entries.length,
117
+ signed: checkSigs,
118
+ };
119
+
120
+ if (breakAt) return { kind: "altered", breakSeq: breakAt.seq, detail: breakAt.detail, ...base };
121
+ if (entries[entries.length - 1].action !== "package sealed") {
122
+ return { kind: "unsealed", detail: "the chain recomputes, but the package was never sealed; nothing attests that this is a delivered record", ...base };
123
+ }
124
+ return { kind: "holds", ...base };
125
+ };