@passportsign/core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/badge.d.ts +5 -0
- package/dist/badge.d.ts.map +1 -1
- package/dist/badge.js +8 -2
- package/dist/badge.js.map +1 -1
- package/dist/bind.d.ts.map +1 -1
- package/dist/bind.js +2 -8
- package/dist/bind.js.map +1 -1
- package/dist/bundle-fs.d.ts +16 -0
- package/dist/bundle-fs.d.ts.map +1 -0
- package/dist/bundle-fs.js +31 -0
- package/dist/bundle-fs.js.map +1 -0
- package/dist/bundle.d.ts +13 -5
- package/dist/bundle.d.ts.map +1 -1
- package/dist/bundle.js +18 -20
- package/dist/bundle.js.map +1 -1
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +3 -4
- package/dist/canonical.js.map +1 -1
- package/dist/classify.d.ts +68 -0
- package/dist/classify.d.ts.map +1 -0
- package/dist/classify.js +117 -0
- package/dist/classify.js.map +1 -0
- package/dist/dsse-common.d.ts +32 -0
- package/dist/dsse-common.d.ts.map +1 -0
- package/dist/dsse-common.js +26 -0
- package/dist/dsse-common.js.map +1 -0
- package/dist/dsse-web.d.ts +28 -0
- package/dist/dsse-web.d.ts.map +1 -0
- package/dist/dsse-web.js +81 -0
- package/dist/dsse-web.js.map +1 -0
- package/dist/dsse.d.ts +2 -26
- package/dist/dsse.d.ts.map +1 -1
- package/dist/dsse.js +2 -19
- package/dist/dsse.js.map +1 -1
- package/dist/encoding.d.ts +20 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +88 -0
- package/dist/encoding.js.map +1 -0
- package/dist/github.js +2 -2
- package/dist/github.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/log/rekor.d.ts +1 -1
- package/dist/log/rekor.d.ts.map +1 -1
- package/dist/log/rekor.js +7 -10
- package/dist/log/rekor.js.map +1 -1
- package/dist/lookup.d.ts +46 -0
- package/dist/lookup.d.ts.map +1 -0
- package/dist/lookup.js +101 -0
- package/dist/lookup.js.map +1 -0
- package/dist/merkle.js +3 -3
- package/dist/merkle.js.map +1 -1
- package/dist/nonce.js +1 -1
- package/dist/nonce.js.map +1 -1
- package/dist/profile-index.d.ts +64 -0
- package/dist/profile-index.d.ts.map +1 -0
- package/dist/profile-index.js +161 -0
- package/dist/profile-index.js.map +1 -0
- package/dist/revoke.d.ts +30 -0
- package/dist/revoke.d.ts.map +1 -0
- package/dist/revoke.js +42 -0
- package/dist/revoke.js.map +1 -0
- package/dist/sdk-payload.d.ts.map +1 -1
- package/dist/sdk-payload.js +4 -6
- package/dist/sdk-payload.js.map +1 -1
- package/dist/statement.d.ts +41 -0
- package/dist/statement.d.ts.map +1 -1
- package/dist/statement.js +43 -0
- package/dist/statement.js.map +1 -1
- package/dist/submit.d.ts +3 -3
- package/dist/submit.d.ts.map +1 -1
- package/dist/submit.js +3 -14
- package/dist/submit.js.map +1 -1
- package/dist/verifier.d.ts.map +1 -1
- package/dist/verifier.js +4 -14
- package/dist/verifier.js.map +1 -1
- package/dist/web.d.ts +35 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +35 -0
- package/dist/web.js.map +1 -0
- package/package.json +6 -2
- package/src/badge.ts +124 -113
- package/src/bind.ts +128 -137
- package/src/bundle-fs.ts +40 -0
- package/src/bundle.ts +138 -127
- package/src/canonical.ts +33 -33
- package/src/classify.ts +165 -0
- package/src/dsse-common.ts +45 -0
- package/src/dsse-web.ts +97 -0
- package/src/dsse.ts +63 -91
- package/src/encoding.ts +96 -0
- package/src/github.ts +196 -196
- package/src/index.ts +59 -2
- package/src/log/rekor.ts +330 -334
- package/src/lookup.ts +175 -0
- package/src/merkle.ts +187 -187
- package/src/nonce.ts +53 -53
- package/src/profile-index.ts +222 -0
- package/src/revoke.ts +67 -0
- package/src/sdk-payload.ts +60 -62
- package/src/statement.ts +203 -119
- package/src/submit.ts +38 -54
- package/src/verifier.ts +304 -317
- package/src/web.ts +175 -0
package/src/classify.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse Rekor in-toto entries back into statements and classify
|
|
3
|
+
* binding state (active / stale / revoked).
|
|
4
|
+
*
|
|
5
|
+
* This is the read-side counterpart of `statement.ts`/`submit.ts`:
|
|
6
|
+
* the `list` command and the badge service both consume entries the
|
|
7
|
+
* profile index points at, and neither may trust the index — every
|
|
8
|
+
* entry is integrity-checked against the hash Rekor recorded.
|
|
9
|
+
*
|
|
10
|
+
* Staleness (spec §10 row 1): a binding moves to `stale` 12 months
|
|
11
|
+
* after its Rekor inclusion time. `integratedTime` is authoritative;
|
|
12
|
+
* user-supplied dates in the index are display-only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { base64ToBytes, bytesToUtf8, sha256Hex } from './encoding.js';
|
|
16
|
+
import { type RekorEntryResponse } from './log/rekor.js';
|
|
17
|
+
import {
|
|
18
|
+
IN_TOTO_STATEMENT_TYPE,
|
|
19
|
+
PASSPORTSIGN_REVOCATION_PREDICATE_TYPE,
|
|
20
|
+
} from './statement.js';
|
|
21
|
+
|
|
22
|
+
/** Spec §10 row 1: bindings move to `stale` after 12 months. */
|
|
23
|
+
export const STALENESS_WINDOW_MS = 365 * 24 * 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
export class EntryParseError extends Error {
|
|
26
|
+
constructor(message: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'EntryParseError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Generic in-toto Statement v1 shape (predicate left untyped — callers narrow). */
|
|
33
|
+
export interface InTotoStatement {
|
|
34
|
+
_type: typeof IN_TOTO_STATEMENT_TYPE;
|
|
35
|
+
subject: Array<{ name: string; digest: Record<string, string> }>;
|
|
36
|
+
predicateType: string;
|
|
37
|
+
predicate: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ParsedIntotoEntry {
|
|
41
|
+
uuid: string;
|
|
42
|
+
/** Unix seconds — Rekor's authoritative inclusion time. */
|
|
43
|
+
integratedTime: number;
|
|
44
|
+
predicateType: string;
|
|
45
|
+
statement: InTotoStatement;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fail(message: string): never {
|
|
49
|
+
throw new EntryParseError(message);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Decode a Rekor in-toto entry's stored attestation back into its
|
|
54
|
+
* statement, verifying the attestation bytes hash to the
|
|
55
|
+
* `payloadHash` Rekor recorded in the entry body. The hash check is
|
|
56
|
+
* what lets consumers trust an entry fetched via an untrusted index.
|
|
57
|
+
*/
|
|
58
|
+
export function parseIntotoEntry(entry: RekorEntryResponse): ParsedIntotoEntry {
|
|
59
|
+
const data = entry.attestation?.data;
|
|
60
|
+
if (typeof data !== 'string' || data.length === 0) {
|
|
61
|
+
fail(`entry ${entry.uuid}: no stored attestation`);
|
|
62
|
+
}
|
|
63
|
+
const decoded = (() => {
|
|
64
|
+
try {
|
|
65
|
+
return {
|
|
66
|
+
attestationBytes: base64ToBytes(data),
|
|
67
|
+
bodyObj: JSON.parse(bytesToUtf8(base64ToBytes(entry.body))) as unknown,
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
fail(`entry ${entry.uuid}: attestation/body is not base64 JSON`);
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
const { attestationBytes, bodyObj } = decoded;
|
|
74
|
+
const payloadHash = (
|
|
75
|
+
bodyObj as { spec?: { content?: { payloadHash?: { algorithm?: string; value?: string } } } }
|
|
76
|
+
)?.spec?.content?.payloadHash;
|
|
77
|
+
if (payloadHash?.algorithm !== 'sha256' || typeof payloadHash.value !== 'string') {
|
|
78
|
+
fail(`entry ${entry.uuid}: body has no sha256 payloadHash`);
|
|
79
|
+
}
|
|
80
|
+
const computed = sha256Hex(attestationBytes);
|
|
81
|
+
if (computed !== payloadHash.value) {
|
|
82
|
+
fail(
|
|
83
|
+
`entry ${entry.uuid}: attestation hash mismatch (computed ${computed}, recorded ${payloadHash.value})`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let statement: unknown;
|
|
88
|
+
try {
|
|
89
|
+
statement = JSON.parse(bytesToUtf8(attestationBytes));
|
|
90
|
+
} catch {
|
|
91
|
+
fail(`entry ${entry.uuid}: attestation is not JSON`);
|
|
92
|
+
}
|
|
93
|
+
const s = statement as Partial<InTotoStatement>;
|
|
94
|
+
if (
|
|
95
|
+
s?._type !== IN_TOTO_STATEMENT_TYPE ||
|
|
96
|
+
!Array.isArray(s.subject) ||
|
|
97
|
+
typeof s.predicateType !== 'string'
|
|
98
|
+
) {
|
|
99
|
+
fail(`entry ${entry.uuid}: attestation is not an in-toto Statement v1`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
uuid: entry.uuid,
|
|
104
|
+
integratedTime: entry.integratedTime,
|
|
105
|
+
predicateType: s.predicateType,
|
|
106
|
+
statement: s as InTotoStatement,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type BindingState = 'active' | 'stale' | 'revoked';
|
|
111
|
+
|
|
112
|
+
export interface ClassifiedBinding {
|
|
113
|
+
entry: ParsedIntotoEntry;
|
|
114
|
+
state: BindingState;
|
|
115
|
+
/** UUID of the revocation entry that caused `revoked`, when applicable. */
|
|
116
|
+
revokedBy?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ClassifyBindingsInput {
|
|
120
|
+
bindings: ParsedIntotoEntry[];
|
|
121
|
+
revocations: ParsedIntotoEntry[];
|
|
122
|
+
/** Epoch milliseconds; defaults to the current time. */
|
|
123
|
+
now?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function predicateField(entry: ParsedIntotoEntry, field: string): string | undefined {
|
|
127
|
+
const predicate = entry.statement.predicate;
|
|
128
|
+
if (typeof predicate !== 'object' || predicate === null) return undefined;
|
|
129
|
+
const value = (predicate as Record<string, unknown>)[field];
|
|
130
|
+
return typeof value === 'string' ? value : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Classify each binding:
|
|
135
|
+
* - `revoked` if a revocation entry (predicateType `…#revocation`)
|
|
136
|
+
* carries the same `unique_identifier` and either targets this
|
|
137
|
+
* binding's UUID via `revokes_rekor_entry_hash` or has no target
|
|
138
|
+
* (revokes all bindings under that identifier);
|
|
139
|
+
* - else `stale` if older than {@link STALENESS_WINDOW_MS};
|
|
140
|
+
* - else `active`.
|
|
141
|
+
*/
|
|
142
|
+
export function classifyBindings(input: ClassifyBindingsInput): ClassifiedBinding[] {
|
|
143
|
+
const now = input.now ?? Date.now();
|
|
144
|
+
// Exact match, not endsWith — a foreign predicateType that happens to
|
|
145
|
+
// end in #revocation must never revoke a binding.
|
|
146
|
+
const revocations = input.revocations.filter(
|
|
147
|
+
(r) => r.predicateType === PASSPORTSIGN_REVOCATION_PREDICATE_TYPE,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return input.bindings.map((entry) => {
|
|
151
|
+
const uid = predicateField(entry, 'unique_identifier');
|
|
152
|
+
const revokedBy = revocations.find((r) => {
|
|
153
|
+
if (predicateField(r, 'unique_identifier') !== uid || uid === undefined) return false;
|
|
154
|
+
const target = predicateField(r, 'revokes_rekor_entry_hash');
|
|
155
|
+
return target === undefined || target === entry.uuid;
|
|
156
|
+
});
|
|
157
|
+
if (revokedBy) {
|
|
158
|
+
return { entry, state: 'revoked', revokedBy: revokedBy.uuid };
|
|
159
|
+
}
|
|
160
|
+
if (now - entry.integratedTime * 1000 > STALENESS_WINDOW_MS) {
|
|
161
|
+
return { entry, state: 'stale' };
|
|
162
|
+
}
|
|
163
|
+
return { entry, state: 'active' };
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-neutral DSSE pieces shared by the node signer (`dsse.ts`)
|
|
3
|
+
* and the WebCrypto signer (`dsse-web.ts`): the envelope shape and the
|
|
4
|
+
* Pre-Authentication Encoding.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { utf8ToBytes } from './encoding.js';
|
|
8
|
+
|
|
9
|
+
export const DSSE_VERSION = 'DSSEv1';
|
|
10
|
+
export const IN_TOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json';
|
|
11
|
+
|
|
12
|
+
export interface DsseSignature {
|
|
13
|
+
/** Single-base64 of the raw signature bytes. */
|
|
14
|
+
sig: string;
|
|
15
|
+
/** PEM-encoded SubjectPublicKeyInfo. */
|
|
16
|
+
publicKey: string;
|
|
17
|
+
/** Optional key identifier. Omit (don't pass empty string) when not set. */
|
|
18
|
+
keyid?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DsseEnvelope {
|
|
22
|
+
/** Media type of the payload (e.g. `application/vnd.in-toto+json`). */
|
|
23
|
+
payloadType: string;
|
|
24
|
+
/** Single-base64 of the raw payload bytes. */
|
|
25
|
+
payload: string;
|
|
26
|
+
signatures: DsseSignature[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* DSSE Pre-Authentication Encoding (PAE):
|
|
31
|
+
*
|
|
32
|
+
* "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
|
|
33
|
+
*
|
|
34
|
+
* Where SP is a single 0x20 space, LEN is the ASCII-decimal length of
|
|
35
|
+
* the following byte string.
|
|
36
|
+
*/
|
|
37
|
+
export function pae(type: string, body: Uint8Array): Uint8Array {
|
|
38
|
+
const typeBytes = utf8ToBytes(type);
|
|
39
|
+
const prefix = `${DSSE_VERSION} ${typeBytes.length} ${type} ${body.length} `;
|
|
40
|
+
const prefixBytes = utf8ToBytes(prefix);
|
|
41
|
+
const out = new Uint8Array(prefixBytes.length + body.length);
|
|
42
|
+
out.set(prefixBytes);
|
|
43
|
+
out.set(body, prefixBytes.length);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
package/src/dsse-web.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebCrypto variant of the DSSE envelope signer for runtimes without
|
|
3
|
+
* `node:crypto` sign APIs (browsers, edge workers). Same semantics as
|
|
4
|
+
* `dsse.ts`'s `signEnvelope`: ephemeral ECDSA P-256 key, discarded
|
|
5
|
+
* after signing; the signature is a Rekor schema requirement, not a
|
|
6
|
+
* trust mechanism.
|
|
7
|
+
*
|
|
8
|
+
* Two impedance mismatches with what Rekor expects, both handled here:
|
|
9
|
+
* - WebCrypto emits raw P1363 (`r || s`) signatures; Rekor needs DER.
|
|
10
|
+
* - WebCrypto exports SPKI as raw bytes; Rekor needs PEM text.
|
|
11
|
+
*
|
|
12
|
+
* The drift test in `test/dsse-web.test.ts` verifies output with
|
|
13
|
+
* `node:crypto.createVerify` so the two signers cannot diverge silently.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { bytesToBase64 } from './encoding.js';
|
|
17
|
+
import { pae, type DsseEnvelope } from './dsse-common.js';
|
|
18
|
+
|
|
19
|
+
export interface SignEnvelopeWebResult {
|
|
20
|
+
envelope: DsseEnvelope;
|
|
21
|
+
publicKeyPem: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Strip leading zero bytes, then re-add one if the high bit is set (DER INTEGER rule). */
|
|
25
|
+
function derInteger(bytes: Uint8Array): Uint8Array {
|
|
26
|
+
let start = 0;
|
|
27
|
+
while (start < bytes.length - 1 && bytes[start] === 0) start++;
|
|
28
|
+
const trimmed = bytes.subarray(start);
|
|
29
|
+
if (trimmed[0]! & 0x80) {
|
|
30
|
+
const padded = new Uint8Array(trimmed.length + 1);
|
|
31
|
+
padded.set(trimmed, 1);
|
|
32
|
+
return padded;
|
|
33
|
+
}
|
|
34
|
+
return trimmed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Convert a P1363 (r||s) ECDSA signature to DER SEQUENCE(INTEGER r, INTEGER s). */
|
|
38
|
+
export function p1363ToDer(sig: Uint8Array): Uint8Array {
|
|
39
|
+
if (sig.length % 2 !== 0) {
|
|
40
|
+
throw new TypeError(`p1363ToDer: signature length ${sig.length} is not even`);
|
|
41
|
+
}
|
|
42
|
+
const half = sig.length / 2;
|
|
43
|
+
const r = derInteger(sig.subarray(0, half));
|
|
44
|
+
const s = derInteger(sig.subarray(half));
|
|
45
|
+
const body = new Uint8Array(2 + r.length + 2 + s.length);
|
|
46
|
+
body[0] = 0x02;
|
|
47
|
+
body[1] = r.length;
|
|
48
|
+
body.set(r, 2);
|
|
49
|
+
body[2 + r.length] = 0x02;
|
|
50
|
+
body[3 + r.length] = s.length;
|
|
51
|
+
body.set(s, 4 + r.length);
|
|
52
|
+
// P-256 DER bodies are < 128 bytes, so a single length byte suffices.
|
|
53
|
+
const out = new Uint8Array(2 + body.length);
|
|
54
|
+
out[0] = 0x30;
|
|
55
|
+
out[1] = body.length;
|
|
56
|
+
out.set(body, 2);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function spkiToPem(spki: Uint8Array): string {
|
|
61
|
+
const b64 = bytesToBase64(spki);
|
|
62
|
+
const lines = b64.match(/.{1,64}/g) ?? [];
|
|
63
|
+
return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----\n`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Generate an ephemeral ECDSA P-256 keypair via WebCrypto, sign
|
|
68
|
+
* PAE(payloadType, payload), and return a DSSE envelope. Async because
|
|
69
|
+
* WebCrypto is; otherwise interchangeable with `signEnvelope`.
|
|
70
|
+
*/
|
|
71
|
+
export async function signEnvelopeWeb(
|
|
72
|
+
payload: Uint8Array,
|
|
73
|
+
payloadType: string,
|
|
74
|
+
): Promise<SignEnvelopeWebResult> {
|
|
75
|
+
const subtle = globalThis.crypto.subtle;
|
|
76
|
+
const keyPair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
|
77
|
+
'sign',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const paeBytes = pae(payloadType, payload);
|
|
81
|
+
const rawSig = new Uint8Array(
|
|
82
|
+
await subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, paeBytes),
|
|
83
|
+
);
|
|
84
|
+
const derSig = p1363ToDer(rawSig);
|
|
85
|
+
|
|
86
|
+
const spki = new Uint8Array(await subtle.exportKey('spki', keyPair.publicKey));
|
|
87
|
+
const publicKeyPem = spkiToPem(spki);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
envelope: {
|
|
91
|
+
payloadType,
|
|
92
|
+
payload: bytesToBase64(payload),
|
|
93
|
+
signatures: [{ sig: bytesToBase64(derSig), publicKey: publicKeyPem }],
|
|
94
|
+
},
|
|
95
|
+
publicKeyPem,
|
|
96
|
+
};
|
|
97
|
+
}
|
package/src/dsse.ts
CHANGED
|
@@ -1,91 +1,63 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DSSE (Dead Simple Signing Envelope) envelope builder.
|
|
3
|
-
*
|
|
4
|
-
* Per-binding ephemeral ECDSA P-256 key — the private key is discarded
|
|
5
|
-
* after signing. The DSSE signature is a Rekor schema requirement, not
|
|
6
|
-
* a trust mechanism. The actual authentication for passportsign comes
|
|
7
|
-
* from the zkPassport proof + GitHub gist evidence carried inside the
|
|
8
|
-
* statement's predicate, not from this signature.
|
|
9
|
-
*
|
|
10
|
-
* Spec: https://github.com/secure-systems-lab/dsse/blob/master/protocol.md
|
|
11
|
-
*
|
|
12
|
-
* Note on key algorithm choice: ECDSA P-256 over SHA-256 is what
|
|
13
|
-
* Rekor's public instance accepts for intoto v0.0.2 entries. Ed25519
|
|
14
|
-
* is in the DSSE spec but the public Rekor's verification path rejected
|
|
15
|
-
* it during the Day 5 smoke test (500 "error generating canonicalized
|
|
16
|
-
* entry"). See `docs/v0-acceptance.md` Day 5 evidence.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { createSign, generateKeyPairSync } from 'node:crypto';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Generate an ephemeral ECDSA P-256 keypair, sign PAE(payloadType,
|
|
67
|
-
* payload), and return a DSSE envelope. The private key is discarded
|
|
68
|
-
* before return.
|
|
69
|
-
*/
|
|
70
|
-
export function signEnvelope(payload: Uint8Array, payloadType: string): SignEnvelopeResult {
|
|
71
|
-
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
72
|
-
const paeBytes = pae(payloadType, payload);
|
|
73
|
-
const signer = createSign('SHA256');
|
|
74
|
-
signer.update(Buffer.from(paeBytes));
|
|
75
|
-
const sigBuf = signer.sign(privateKey);
|
|
76
|
-
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
|
|
77
|
-
|
|
78
|
-
return {
|
|
79
|
-
envelope: {
|
|
80
|
-
payloadType,
|
|
81
|
-
payload: Buffer.from(payload).toString('base64'),
|
|
82
|
-
signatures: [
|
|
83
|
-
{
|
|
84
|
-
sig: sigBuf.toString('base64'),
|
|
85
|
-
publicKey: publicKeyPem,
|
|
86
|
-
},
|
|
87
|
-
],
|
|
88
|
-
},
|
|
89
|
-
publicKeyPem,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* DSSE (Dead Simple Signing Envelope) envelope builder.
|
|
3
|
+
*
|
|
4
|
+
* Per-binding ephemeral ECDSA P-256 key — the private key is discarded
|
|
5
|
+
* after signing. The DSSE signature is a Rekor schema requirement, not
|
|
6
|
+
* a trust mechanism. The actual authentication for passportsign comes
|
|
7
|
+
* from the zkPassport proof + GitHub gist evidence carried inside the
|
|
8
|
+
* statement's predicate, not from this signature.
|
|
9
|
+
*
|
|
10
|
+
* Spec: https://github.com/secure-systems-lab/dsse/blob/master/protocol.md
|
|
11
|
+
*
|
|
12
|
+
* Note on key algorithm choice: ECDSA P-256 over SHA-256 is what
|
|
13
|
+
* Rekor's public instance accepts for intoto v0.0.2 entries. Ed25519
|
|
14
|
+
* is in the DSSE spec but the public Rekor's verification path rejected
|
|
15
|
+
* it during the Day 5 smoke test (500 "error generating canonicalized
|
|
16
|
+
* entry"). See `docs/v0-acceptance.md` Day 5 evidence.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createSign, generateKeyPairSync } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
import { pae, type DsseEnvelope } from './dsse-common.js';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
DSSE_VERSION,
|
|
25
|
+
IN_TOTO_PAYLOAD_TYPE,
|
|
26
|
+
pae,
|
|
27
|
+
type DsseEnvelope,
|
|
28
|
+
type DsseSignature,
|
|
29
|
+
} from './dsse-common.js';
|
|
30
|
+
|
|
31
|
+
export interface SignEnvelopeResult {
|
|
32
|
+
envelope: DsseEnvelope;
|
|
33
|
+
/** PEM of the ephemeral public key (also embedded in envelope.signatures[0].publicKey). */
|
|
34
|
+
publicKeyPem: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate an ephemeral ECDSA P-256 keypair, sign PAE(payloadType,
|
|
39
|
+
* payload), and return a DSSE envelope. The private key is discarded
|
|
40
|
+
* before return.
|
|
41
|
+
*/
|
|
42
|
+
export function signEnvelope(payload: Uint8Array, payloadType: string): SignEnvelopeResult {
|
|
43
|
+
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
44
|
+
const paeBytes = pae(payloadType, payload);
|
|
45
|
+
const signer = createSign('SHA256');
|
|
46
|
+
signer.update(Buffer.from(paeBytes));
|
|
47
|
+
const sigBuf = signer.sign(privateKey);
|
|
48
|
+
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
envelope: {
|
|
52
|
+
payloadType,
|
|
53
|
+
payload: Buffer.from(payload).toString('base64'),
|
|
54
|
+
signatures: [
|
|
55
|
+
{
|
|
56
|
+
sig: sigBuf.toString('base64'),
|
|
57
|
+
publicKey: publicKeyPem,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
publicKeyPem,
|
|
62
|
+
};
|
|
63
|
+
}
|
package/src/encoding.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-neutral byte/string primitives.
|
|
3
|
+
*
|
|
4
|
+
* Every module that needs hashing or hex/base64 goes through here so
|
|
5
|
+
* the rest of core has no `node:crypto` / `Buffer` dependency and runs
|
|
6
|
+
* unchanged on Node, Cloudflare Workers, and browsers. SHA-256 comes
|
|
7
|
+
* from `@noble/hashes` (pure JS, synchronous — WebCrypto's async
|
|
8
|
+
* digest would force async signatures through the whole verify path).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
12
|
+
|
|
13
|
+
const HEX_CHARS = '0123456789abcdef';
|
|
14
|
+
|
|
15
|
+
export function bytesToHex(bytes: Uint8Array): string {
|
|
16
|
+
let out = '';
|
|
17
|
+
for (const b of bytes) {
|
|
18
|
+
out += HEX_CHARS[b >> 4]! + HEX_CHARS[b & 0x0f]!;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hexToBytes(hex: string): Uint8Array {
|
|
24
|
+
if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) {
|
|
25
|
+
throw new TypeError(`hexToBytes: invalid hex string (length ${hex.length})`);
|
|
26
|
+
}
|
|
27
|
+
const out = new Uint8Array(hex.length / 2);
|
|
28
|
+
for (let i = 0; i < out.length; i++) {
|
|
29
|
+
out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
35
|
+
const B64_LOOKUP: Record<string, number> = {};
|
|
36
|
+
for (let i = 0; i < B64_ALPHABET.length; i++) B64_LOOKUP[B64_ALPHABET[i]!] = i;
|
|
37
|
+
|
|
38
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
39
|
+
let out = '';
|
|
40
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
41
|
+
const b0 = bytes[i]!;
|
|
42
|
+
const b1 = i + 1 < bytes.length ? bytes[i + 1]! : 0;
|
|
43
|
+
const b2 = i + 2 < bytes.length ? bytes[i + 2]! : 0;
|
|
44
|
+
out += B64_ALPHABET[b0 >> 2]!;
|
|
45
|
+
out += B64_ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)]!;
|
|
46
|
+
out += i + 1 < bytes.length ? B64_ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)]! : '=';
|
|
47
|
+
out += i + 2 < bytes.length ? B64_ALPHABET[b2 & 0x3f]! : '=';
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function base64ToBytes(b64: string): Uint8Array {
|
|
53
|
+
if (b64.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(b64)) {
|
|
54
|
+
throw new TypeError('base64ToBytes: invalid base64 string');
|
|
55
|
+
}
|
|
56
|
+
const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0;
|
|
57
|
+
const byteLength = (b64.length / 4) * 3 - padding;
|
|
58
|
+
const out = new Uint8Array(byteLength);
|
|
59
|
+
let outIdx = 0;
|
|
60
|
+
for (let i = 0; i < b64.length; i += 4) {
|
|
61
|
+
const c0 = B64_LOOKUP[b64[i]!]!;
|
|
62
|
+
const c1 = B64_LOOKUP[b64[i + 1]!]!;
|
|
63
|
+
const c2 = b64[i + 2] === '=' ? 0 : B64_LOOKUP[b64[i + 2]!]!;
|
|
64
|
+
const c3 = b64[i + 3] === '=' ? 0 : B64_LOOKUP[b64[i + 3]!]!;
|
|
65
|
+
if (outIdx < byteLength) out[outIdx++] = (c0 << 2) | (c1 >> 4);
|
|
66
|
+
if (outIdx < byteLength) out[outIdx++] = ((c1 & 0x0f) << 4) | (c2 >> 2);
|
|
67
|
+
if (outIdx < byteLength) out[outIdx++] = ((c2 & 0x03) << 6) | c3;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const textEncoder = new TextEncoder();
|
|
73
|
+
const textDecoder = new TextDecoder();
|
|
74
|
+
|
|
75
|
+
export function utf8ToBytes(s: string): Uint8Array {
|
|
76
|
+
return textEncoder.encode(s);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function bytesToUtf8(bytes: Uint8Array): string {
|
|
80
|
+
return textDecoder.decode(bytes);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function sha256Bytes(bytes: Uint8Array): Uint8Array {
|
|
84
|
+
return sha256(bytes);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function sha256Hex(bytes: Uint8Array): string {
|
|
88
|
+
return bytesToHex(sha256(bytes));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Cryptographically secure random bytes via the platform's WebCrypto. */
|
|
92
|
+
export function randomBytes(length: number): Uint8Array {
|
|
93
|
+
const out = new Uint8Array(length);
|
|
94
|
+
globalThis.crypto.getRandomValues(out);
|
|
95
|
+
return out;
|
|
96
|
+
}
|