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 +202 -0
- package/README.md +55 -0
- package/docs/FORMAT.md +210 -0
- package/index.js +14 -0
- package/package.json +39 -0
- package/src/append.js +17 -0
- package/src/artifact.js +38 -0
- package/src/attest.js +41 -0
- package/src/chain.js +68 -0
- package/src/crypto.js +45 -0
- package/src/pcm.js +54 -0
- package/src/pkg.js +41 -0
- package/src/verify.js +125 -0
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
|
+
};
|
package/src/artifact.js
ADDED
|
@@ -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
|
+
};
|