phasegate 0.163.0 → 0.165.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/CHANGELOG.md +11 -0
- package/docs/ADR/019-ai-independence-boundary.md +63 -0
- package/docs/ADR/020-reverse-learning-forward-proposal.md +52 -0
- package/package.json +1 -1
- package/scripts/harness/attestation/application/dto/attestation-document.ts +54 -0
- package/scripts/harness/attestation/application/dto/produce-attestation-input.ts +18 -0
- package/scripts/harness/attestation/application/dto/verify-attestation-input.ts +12 -0
- package/scripts/harness/attestation/application/dto/verify-attestation-output.ts +30 -0
- package/scripts/harness/attestation/application/mappers/attestation-record-mapper.ts +240 -0
- package/scripts/harness/attestation/application/ports/attestation-repository-port.ts +14 -0
- package/scripts/harness/attestation/application/ports/gate-result-source-port.ts +23 -0
- package/scripts/harness/attestation/application/ports/source-digester-port.ts +15 -0
- package/scripts/harness/attestation/application/usecases/produce-attestation-usecase.ts +155 -0
- package/scripts/harness/attestation/application/usecases/verify-attestation-usecase.ts +137 -0
- package/scripts/harness/attestation/composition-root.ts +93 -0
- package/scripts/harness/attestation/domain/entities/attestation-record.ts +240 -0
- package/scripts/harness/attestation/domain/ports/content-hasher-port.ts +13 -0
- package/scripts/harness/attestation/domain/services/granularity-derivation-service.ts +51 -0
- package/scripts/harness/attestation/domain/value-objects/digest.ts +51 -0
- package/scripts/harness/attestation/domain/value-objects/granularity-claim.ts +89 -0
- package/scripts/harness/attestation/domain/value-objects/signature-block.ts +87 -0
- package/scripts/harness/attestation/domain/value-objects/validator-outcome.ts +44 -0
- package/scripts/harness/attestation/index.ts +18 -0
- package/scripts/harness/attestation/infrastructure/adapters/ci-check-gate-result-adapter.ts +164 -0
- package/scripts/harness/attestation/infrastructure/adapters/file-system-attestation-repository-adapter.ts +32 -0
- package/scripts/harness/attestation/infrastructure/adapters/file-system-source-digester-adapter.ts +27 -0
- package/scripts/harness/attestation/infrastructure/adapters/node-crypto-content-hasher-adapter.ts +18 -0
- package/scripts/harness/attestation/presentation/handlers/attest-handler.ts +71 -0
- package/scripts/harness/attestation/presentation/handlers/verify-attestation-handler.ts +74 -0
- package/scripts/harness/main.ts +51 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// @unit attestation
|
|
2
|
+
// @layer application
|
|
3
|
+
|
|
4
|
+
import { AttestationRecord, type GateResult, type SourceEntry } from "../../domain/entities/attestation-record.js";
|
|
5
|
+
import type { ContentHasherPort } from "../../domain/ports/content-hasher-port.js";
|
|
6
|
+
import type { GranularityDerivationService } from "../../domain/services/granularity-derivation-service.js";
|
|
7
|
+
import { SignatureBlock } from "../../domain/value-objects/signature-block.js";
|
|
8
|
+
import { ValidatorOutcome } from "../../domain/value-objects/validator-outcome.js";
|
|
9
|
+
import type { AttestationDocument } from "../dto/attestation-document.js";
|
|
10
|
+
import type { ProduceAttestationInput } from "../dto/produce-attestation-input.js";
|
|
11
|
+
import type { AttestationRecordMapper } from "../mappers/attestation-record-mapper.js";
|
|
12
|
+
import type { AttestationRepositoryPort } from "../ports/attestation-repository-port.js";
|
|
13
|
+
import type { GateResultSourcePort } from "../ports/gate-result-source-port.js";
|
|
14
|
+
import type { SourceDigesterPort } from "../ports/source-digester-port.js";
|
|
15
|
+
|
|
16
|
+
const SCHEMA_VERSION = "phasegate-attestation/v1";
|
|
17
|
+
const PREDICATE_TYPE = "https://phasegate.dev/attestation/gate-run/v1";
|
|
18
|
+
const DEFAULT_INPUT_SOURCES: readonly string[] = Object.freeze([
|
|
19
|
+
"phasegate.config.json",
|
|
20
|
+
".harness/requirement-test-matrix.json",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export interface ProduceAttestationDeps {
|
|
24
|
+
readonly gateResultSource: GateResultSourcePort;
|
|
25
|
+
readonly sourceDigester: SourceDigesterPort;
|
|
26
|
+
readonly hasher: ContentHasherPort;
|
|
27
|
+
readonly repository: AttestationRepositoryPort;
|
|
28
|
+
readonly granularityService: GranularityDerivationService;
|
|
29
|
+
readonly mapper: AttestationRecordMapper;
|
|
30
|
+
/** git commit SHA 取得(決定論的 source 化のため)。取得不能なら null。 */
|
|
31
|
+
readonly gitCommitProvider: () => Promise<string | null>;
|
|
32
|
+
/** metadata.producer の pkg version。 */
|
|
33
|
+
readonly pkgVersion: string;
|
|
34
|
+
/** metadata.producedAt 用クロック。 */
|
|
35
|
+
readonly clock?: () => Date;
|
|
36
|
+
/** 入力 source パス群(既定は config + matrix)。 */
|
|
37
|
+
readonly inputSourcePaths?: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProduceAttestationResult {
|
|
41
|
+
readonly document: AttestationDocument | null;
|
|
42
|
+
readonly exitCode: 0 | 1 | 2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* H16-01: gate 結果取得 → source digest 取得 → granularity 導出 → 集約構築 → 封印 → 出力。
|
|
47
|
+
*/
|
|
48
|
+
export class ProduceAttestationUseCase {
|
|
49
|
+
constructor(private readonly deps: ProduceAttestationDeps) {}
|
|
50
|
+
|
|
51
|
+
async execute(input: ProduceAttestationInput): Promise<ProduceAttestationResult> {
|
|
52
|
+
// 1. mode == "signed" は not-yet-implemented(record 出力なし・exit 2)
|
|
53
|
+
if (input.mode === "signed") {
|
|
54
|
+
return { document: null, exitCode: 2 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 2. gate 結果取得(validatorSet と gateResult 組み立て)
|
|
58
|
+
const gate = await this.deps.gateResultSource.fetchGateResult();
|
|
59
|
+
const validatorSet = gate.validatorResults.map((r) =>
|
|
60
|
+
ValidatorOutcome.create({ validatorId: r.validatorId, passed: r.passed, skipped: r.skipped }),
|
|
61
|
+
);
|
|
62
|
+
const gateResult: GateResult = gate.allPassed ? "pass" : "fail";
|
|
63
|
+
|
|
64
|
+
// 3. require-pass && gate fail → record を一切生成/出力せず exit 1
|
|
65
|
+
if (input.requirePass && gateResult !== "pass") {
|
|
66
|
+
return { document: null, exitCode: 1 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 4. inputs.sources 構築(config / matrix の sha256 + git commit SHA)と inputDigest 算出
|
|
70
|
+
const sources = await this.buildSources();
|
|
71
|
+
const preInputRecord = this.assembleRecord({
|
|
72
|
+
validatorSet,
|
|
73
|
+
gateResult,
|
|
74
|
+
sources,
|
|
75
|
+
inputDigest: sources[0].digest, // 仮置き。直後に computeInputDigest で確定
|
|
76
|
+
producedAt: "1970-01-01T00:00:00Z",
|
|
77
|
+
producer: `phasegate-attestation/${this.deps.pkgVersion}`,
|
|
78
|
+
gitCommit: null,
|
|
79
|
+
});
|
|
80
|
+
const inputDigest = preInputRecord.computeInputDigest(this.deps.hasher);
|
|
81
|
+
|
|
82
|
+
// 5. granularity 導出(domain service)
|
|
83
|
+
// 6. metadata 構築
|
|
84
|
+
const now = (this.deps.clock ?? (() => new Date()))().toISOString();
|
|
85
|
+
const gitCommit = await this.deps.gitCommitProvider();
|
|
86
|
+
|
|
87
|
+
// 7. 集約構築 → seal で attestationDigest 確定(INV-4)
|
|
88
|
+
const record = this.assembleRecord({
|
|
89
|
+
validatorSet,
|
|
90
|
+
gateResult,
|
|
91
|
+
sources,
|
|
92
|
+
inputDigest,
|
|
93
|
+
producedAt: now,
|
|
94
|
+
producer: `phasegate-attestation/${this.deps.pkgVersion}`,
|
|
95
|
+
gitCommit,
|
|
96
|
+
});
|
|
97
|
+
const sealed = record.seal(this.deps.hasher);
|
|
98
|
+
|
|
99
|
+
// 8. document へ射影して永続化
|
|
100
|
+
const document = this.deps.mapper.toDocument(sealed);
|
|
101
|
+
await this.deps.repository.write(input.out, document);
|
|
102
|
+
|
|
103
|
+
// 9. 成功
|
|
104
|
+
return { document, exitCode: 0 };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private assembleRecord(args: {
|
|
108
|
+
validatorSet: ValidatorOutcome[];
|
|
109
|
+
gateResult: GateResult;
|
|
110
|
+
sources: SourceEntry[];
|
|
111
|
+
inputDigest: SourceEntry["digest"];
|
|
112
|
+
producedAt: string;
|
|
113
|
+
producer: string;
|
|
114
|
+
gitCommit: string | null;
|
|
115
|
+
}): AttestationRecord {
|
|
116
|
+
const granularity = this.deps.granularityService.derive(args.validatorSet);
|
|
117
|
+
return AttestationRecord.create({
|
|
118
|
+
schemaVersion: SCHEMA_VERSION,
|
|
119
|
+
predicateType: PREDICATE_TYPE,
|
|
120
|
+
subject: {
|
|
121
|
+
command: "phasegate:ci-check",
|
|
122
|
+
gateResult: args.gateResult,
|
|
123
|
+
validatorSet: args.validatorSet,
|
|
124
|
+
},
|
|
125
|
+
inputs: {
|
|
126
|
+
digestAlgorithm: "sha256",
|
|
127
|
+
sources: args.sources,
|
|
128
|
+
inputDigest: args.inputDigest,
|
|
129
|
+
},
|
|
130
|
+
granularity: { traceability: granularity },
|
|
131
|
+
metadata: {
|
|
132
|
+
producedAt: args.producedAt,
|
|
133
|
+
producer: args.producer,
|
|
134
|
+
gitCommit: args.gitCommit,
|
|
135
|
+
},
|
|
136
|
+
// 仮の unsigned-poc block(seal 前)。seal が正しい digest で置換する。
|
|
137
|
+
signature: SignatureBlock.unsignedPoc(args.inputDigest),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async buildSources(): Promise<SourceEntry[]> {
|
|
142
|
+
const paths = this.deps.inputSourcePaths ?? DEFAULT_INPUT_SOURCES;
|
|
143
|
+
const sources: SourceEntry[] = [];
|
|
144
|
+
for (const path of paths) {
|
|
145
|
+
const digest = await this.deps.sourceDigester.digestFile(path);
|
|
146
|
+
sources.push({ path, digest });
|
|
147
|
+
}
|
|
148
|
+
// git commit SHA を source エントリとして取り込む(inputDigest に含める)
|
|
149
|
+
const gitCommit = await this.deps.gitCommitProvider();
|
|
150
|
+
if (gitCommit !== null) {
|
|
151
|
+
sources.push({ path: "git:HEAD", digest: this.deps.hasher.sha256(gitCommit) });
|
|
152
|
+
}
|
|
153
|
+
return sources;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @unit attestation
|
|
2
|
+
// @layer application
|
|
3
|
+
|
|
4
|
+
import type { AttestationRecord } from "../../domain/entities/attestation-record.js";
|
|
5
|
+
import type { ContentHasherPort } from "../../domain/ports/content-hasher-port.js";
|
|
6
|
+
import type { GranularityDerivationService } from "../../domain/services/granularity-derivation-service.js";
|
|
7
|
+
import type { VerifyAttestationInput } from "../dto/verify-attestation-input.js";
|
|
8
|
+
import type { VerifyAttestationChecks, VerifyAttestationOutput } from "../dto/verify-attestation-output.js";
|
|
9
|
+
import type { AttestationRecordMapper } from "../mappers/attestation-record-mapper.js";
|
|
10
|
+
import type { AttestationRepositoryPort } from "../ports/attestation-repository-port.js";
|
|
11
|
+
import type { SourceDigesterPort } from "../ports/source-digester-port.js";
|
|
12
|
+
|
|
13
|
+
export interface VerifyAttestationDeps {
|
|
14
|
+
readonly repository: AttestationRepositoryPort;
|
|
15
|
+
readonly sourceDigester: SourceDigesterPort;
|
|
16
|
+
readonly hasher: ContentHasherPort;
|
|
17
|
+
readonly granularityService: GranularityDerivationService;
|
|
18
|
+
readonly mapper: AttestationRecordMapper;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface VerifyAttestationResult {
|
|
22
|
+
readonly output: VerifyAttestationOutput;
|
|
23
|
+
readonly exitCode: 0 | 1 | 2;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SUPPORTED_MODE = "unsigned-poc";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* H16-02: 既存 record を読み込み、機械的 5 チェックを再計算で行う。
|
|
30
|
+
*/
|
|
31
|
+
export class VerifyAttestationUseCase {
|
|
32
|
+
constructor(private readonly deps: VerifyAttestationDeps) {}
|
|
33
|
+
|
|
34
|
+
async execute(input: VerifyAttestationInput): Promise<VerifyAttestationResult> {
|
|
35
|
+
// 1. read(不在/parse 失敗 → exitCode 2, schema fail)
|
|
36
|
+
let raw: unknown;
|
|
37
|
+
try {
|
|
38
|
+
raw = await this.deps.repository.read(input.filePath);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return this.fail2({ schema: false }, [`cannot read attestation: ${errMsg(e)}`]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 3(先出し). mode サポート判定は shape に依存しないため、生の signature.mode を先に確認する。
|
|
44
|
+
// 非対応 mode(signed)は exitCode 2(mode check fail)。
|
|
45
|
+
const rawMode = extractMode(raw);
|
|
46
|
+
if (rawMode !== null && rawMode !== SUPPORTED_MODE) {
|
|
47
|
+
return this.fail2({ schema: true, mode: false }, [`unsupported signature mode: "${rawMode}"`]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. shape/型検証(不正 → exitCode 2, schema fail)
|
|
51
|
+
let record: AttestationRecord;
|
|
52
|
+
try {
|
|
53
|
+
record = this.deps.mapper.fromDocument(raw);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return this.fail2({ schema: false }, [`malformed attestation: ${errMsg(e)}`]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ここまで schema OK, mode OK
|
|
59
|
+
const mismatches: string[] = [];
|
|
60
|
+
|
|
61
|
+
// 4. attestationDigest 再計算 == 格納値
|
|
62
|
+
const recomputedAttestation = record.computeAttestationDigest(this.deps.hasher);
|
|
63
|
+
const attestationDigestOk = recomputedAttestation.equals(record.signature.attestationDigest);
|
|
64
|
+
if (!attestationDigestOk) {
|
|
65
|
+
mismatches.push(
|
|
66
|
+
`attestationDigest mismatch: stored ${record.signature.attestationDigest.value}, recomputed ${recomputedAttestation.value}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 5. inputs.sources[].digest を現在ファイルから再計算 == 格納値
|
|
71
|
+
let inputHashesOk = true;
|
|
72
|
+
for (const source of record.inputs.sources) {
|
|
73
|
+
if (source.path === "git:HEAD") {
|
|
74
|
+
// git commit source は現在の HEAD ではなく生成時点の値を証明する。
|
|
75
|
+
// ファイル再読込の対象外(再照合は inputDigest 経由の整合で担保)。
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
let current: string;
|
|
79
|
+
try {
|
|
80
|
+
current = (await this.deps.sourceDigester.digestFile(source.path)).value;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
inputHashesOk = false;
|
|
83
|
+
mismatches.push(`cannot re-hash source "${source.path}": ${errMsg(e)}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (current !== source.digest.value) {
|
|
87
|
+
inputHashesOk = false;
|
|
88
|
+
mismatches.push(`input hash mismatch for "${source.path}": stored ${source.digest.value}, current ${current}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 6. granularity を validatorSet から再導出 == 格納値(anti-laundering)
|
|
93
|
+
const rederived = this.deps.granularityService.derive(record.subject.validatorSet);
|
|
94
|
+
const granularityOk = rederived.equals(record.granularity.traceability);
|
|
95
|
+
if (!granularityOk) {
|
|
96
|
+
mismatches.push("granularity mismatch: stored granularity does not match re-derived value");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const checks: VerifyAttestationChecks = {
|
|
100
|
+
schema: true,
|
|
101
|
+
mode: true,
|
|
102
|
+
attestationDigest: attestationDigestOk,
|
|
103
|
+
inputHashes: inputHashesOk,
|
|
104
|
+
granularity: granularityOk,
|
|
105
|
+
};
|
|
106
|
+
const ok = attestationDigestOk && inputHashesOk && granularityOk;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
output: { ok, checks, mismatches },
|
|
110
|
+
exitCode: ok ? 0 : 1,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private fail2(partialChecks: Partial<VerifyAttestationChecks>, mismatches: string[]): VerifyAttestationResult {
|
|
115
|
+
const checks: VerifyAttestationChecks = {
|
|
116
|
+
schema: false,
|
|
117
|
+
mode: false,
|
|
118
|
+
attestationDigest: false,
|
|
119
|
+
inputHashes: false,
|
|
120
|
+
granularity: false,
|
|
121
|
+
...partialChecks,
|
|
122
|
+
};
|
|
123
|
+
return { output: { ok: false, checks, mismatches }, exitCode: 2 };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function errMsg(e: unknown): string {
|
|
128
|
+
return e instanceof Error ? e.message : String(e);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function extractMode(raw: unknown): string | null {
|
|
132
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
133
|
+
const signature = (raw as Record<string, unknown>).signature;
|
|
134
|
+
if (typeof signature !== "object" || signature === null) return null;
|
|
135
|
+
const mode = (signature as Record<string, unknown>).mode;
|
|
136
|
+
return typeof mode === "string" ? mode : null;
|
|
137
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer infrastructure
|
|
3
|
+
* @unit attestation
|
|
4
|
+
*
|
|
5
|
+
* Composition Root — attestation Unit の依存性組み立て。
|
|
6
|
+
* createAttestationModule() は adapters + usecases + handlers を組み立て、
|
|
7
|
+
* 公開する 2 ハンドラ(attest / verify-attestation)を返す。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { AttestationRecordMapper } from "./application/mappers/attestation-record-mapper.js";
|
|
13
|
+
import { ProduceAttestationUseCase } from "./application/usecases/produce-attestation-usecase.js";
|
|
14
|
+
import { VerifyAttestationUseCase } from "./application/usecases/verify-attestation-usecase.js";
|
|
15
|
+
import { GranularityDerivationService } from "./domain/services/granularity-derivation-service.js";
|
|
16
|
+
import { CiCheckGateResultAdapter } from "./infrastructure/adapters/ci-check-gate-result-adapter.js";
|
|
17
|
+
import { FileSystemAttestationRepositoryAdapter } from "./infrastructure/adapters/file-system-attestation-repository-adapter.js";
|
|
18
|
+
import { FileSystemSourceDigesterAdapter } from "./infrastructure/adapters/file-system-source-digester-adapter.js";
|
|
19
|
+
import { NodeCryptoContentHasherAdapter } from "./infrastructure/adapters/node-crypto-content-hasher-adapter.js";
|
|
20
|
+
import { AttestHandler } from "./presentation/handlers/attest-handler.js";
|
|
21
|
+
import { VerifyAttestationHandler } from "./presentation/handlers/verify-attestation-handler.js";
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
24
|
+
|
|
25
|
+
export interface AttestationModule {
|
|
26
|
+
readonly attestHandler: AttestHandler;
|
|
27
|
+
readonly verifyAttestationHandler: VerifyAttestationHandler;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AttestationModuleOptions {
|
|
31
|
+
/** metadata.producer の pkg version。既定 "0.0.0"。 */
|
|
32
|
+
readonly pkgVersion?: string;
|
|
33
|
+
/** git commit SHA 取得器のオーバーライド(テスト用)。既定は `git rev-parse HEAD`。 */
|
|
34
|
+
readonly gitCommitProvider?: () => Promise<string | null>;
|
|
35
|
+
/** ci-check subprocess の main.ts パスオーバーライド(テスト用)。 */
|
|
36
|
+
readonly mainTsPath?: string;
|
|
37
|
+
/** metadata.producedAt 用クロックのオーバーライド(テスト用)。 */
|
|
38
|
+
readonly clock?: () => Date;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* attestation Unit のモジュールを組み立てる。
|
|
43
|
+
* @param rootDir プロジェクトルート(source digest / repository の baseDir)。
|
|
44
|
+
*/
|
|
45
|
+
export function createAttestationModule(rootDir: string, options?: AttestationModuleOptions): AttestationModule {
|
|
46
|
+
// Infrastructure adapters
|
|
47
|
+
const hasher = new NodeCryptoContentHasherAdapter();
|
|
48
|
+
const sourceDigester = new FileSystemSourceDigesterAdapter(rootDir);
|
|
49
|
+
const gateResultSource = new CiCheckGateResultAdapter({ mainTsPath: options?.mainTsPath });
|
|
50
|
+
const repository = new FileSystemAttestationRepositoryAdapter(rootDir);
|
|
51
|
+
|
|
52
|
+
// Domain services / mappers
|
|
53
|
+
const granularityService = new GranularityDerivationService();
|
|
54
|
+
const mapper = new AttestationRecordMapper();
|
|
55
|
+
|
|
56
|
+
const gitCommitProvider = options?.gitCommitProvider ?? (() => defaultGitCommitProvider(rootDir));
|
|
57
|
+
|
|
58
|
+
// Use cases
|
|
59
|
+
const produceUseCase = new ProduceAttestationUseCase({
|
|
60
|
+
gateResultSource,
|
|
61
|
+
sourceDigester,
|
|
62
|
+
hasher,
|
|
63
|
+
repository,
|
|
64
|
+
granularityService,
|
|
65
|
+
mapper,
|
|
66
|
+
gitCommitProvider,
|
|
67
|
+
pkgVersion: options?.pkgVersion ?? "0.0.0",
|
|
68
|
+
clock: options?.clock,
|
|
69
|
+
});
|
|
70
|
+
const verifyUseCase = new VerifyAttestationUseCase({
|
|
71
|
+
repository,
|
|
72
|
+
sourceDigester,
|
|
73
|
+
hasher,
|
|
74
|
+
granularityService,
|
|
75
|
+
mapper,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Handlers
|
|
79
|
+
const attestHandler = new AttestHandler(produceUseCase);
|
|
80
|
+
const verifyAttestationHandler = new VerifyAttestationHandler(verifyUseCase);
|
|
81
|
+
|
|
82
|
+
return { attestHandler, verifyAttestationHandler };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function defaultGitCommitProvider(cwd: string): Promise<string | null> {
|
|
86
|
+
try {
|
|
87
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd });
|
|
88
|
+
const sha = stdout.trim();
|
|
89
|
+
return sha.length > 0 ? sha : null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// @unit attestation
|
|
2
|
+
// @layer domain
|
|
3
|
+
|
|
4
|
+
import type { ContentHasherPort } from "../ports/content-hasher-port.js";
|
|
5
|
+
import { GranularityDerivationService } from "../services/granularity-derivation-service.js";
|
|
6
|
+
import type { Digest } from "../value-objects/digest.js";
|
|
7
|
+
import type { GranularityClaim } from "../value-objects/granularity-claim.js";
|
|
8
|
+
import { SignatureBlock } from "../value-objects/signature-block.js";
|
|
9
|
+
import type { ValidatorOutcome } from "../value-objects/validator-outcome.js";
|
|
10
|
+
|
|
11
|
+
export type GateResult = "pass" | "fail";
|
|
12
|
+
|
|
13
|
+
export interface SourceEntry {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly digest: Digest;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SubjectSection {
|
|
19
|
+
readonly command: string;
|
|
20
|
+
readonly gateResult: GateResult;
|
|
21
|
+
readonly validatorSet: readonly ValidatorOutcome[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface InputsSection {
|
|
25
|
+
readonly digestAlgorithm: "sha256";
|
|
26
|
+
readonly sources: readonly SourceEntry[];
|
|
27
|
+
readonly inputDigest: Digest;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MetadataSection {
|
|
31
|
+
readonly producedAt: string;
|
|
32
|
+
readonly producer: string;
|
|
33
|
+
readonly gitCommit: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AttestationRecordProps {
|
|
37
|
+
readonly schemaVersion: string;
|
|
38
|
+
readonly predicateType: string;
|
|
39
|
+
readonly subject: SubjectSection;
|
|
40
|
+
readonly inputs: InputsSection;
|
|
41
|
+
readonly granularity: { readonly traceability: GranularityClaim };
|
|
42
|
+
readonly metadata: MetadataSection;
|
|
43
|
+
readonly signature: SignatureBlock;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* INV-1/2/3/6 のいずれかに違反した場合の集約不変条件違反例外。
|
|
48
|
+
* errorCode: L1-051(logical_design §2.4)
|
|
49
|
+
*/
|
|
50
|
+
export class AttestationInvariantError extends Error {
|
|
51
|
+
readonly errorCode = "L1-051";
|
|
52
|
+
|
|
53
|
+
constructor(message: string) {
|
|
54
|
+
super(`${message} [L1-051]`);
|
|
55
|
+
this.name = "AttestationInvariantError";
|
|
56
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const derivationService = new GranularityDerivationService();
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* canonical JSON 直列化(§1.4.1): キー昇順ソート(再帰)・空白なし・配列順序保持。
|
|
64
|
+
* この規則が record format の中核契約であり、生成と検証で完全一致する必要がある。
|
|
65
|
+
*/
|
|
66
|
+
export function canonicalStringify(value: unknown): string {
|
|
67
|
+
if (value === null || typeof value !== "object") {
|
|
68
|
+
return JSON.stringify(value);
|
|
69
|
+
}
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
return `[${value.map((v) => canonicalStringify(v)).join(",")}]`;
|
|
72
|
+
}
|
|
73
|
+
const record = value as Record<string, unknown>;
|
|
74
|
+
const keys = Object.keys(record).sort();
|
|
75
|
+
const entries = keys.map((k) => `${JSON.stringify(k)}:${canonicalStringify(record[k])}`);
|
|
76
|
+
return `{${entries.join(",")}}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* attestation ドキュメント1件を整合性境界として扱う単一集約。
|
|
81
|
+
*/
|
|
82
|
+
export class AttestationRecord {
|
|
83
|
+
readonly schemaVersion: string;
|
|
84
|
+
readonly predicateType: string;
|
|
85
|
+
readonly subject: SubjectSection;
|
|
86
|
+
readonly inputs: InputsSection;
|
|
87
|
+
readonly granularity: { readonly traceability: GranularityClaim };
|
|
88
|
+
readonly metadata: MetadataSection;
|
|
89
|
+
readonly signature: SignatureBlock;
|
|
90
|
+
|
|
91
|
+
private constructor(props: AttestationRecordProps) {
|
|
92
|
+
this.schemaVersion = props.schemaVersion;
|
|
93
|
+
this.predicateType = props.predicateType;
|
|
94
|
+
this.subject = props.subject;
|
|
95
|
+
this.inputs = props.inputs;
|
|
96
|
+
this.granularity = props.granularity;
|
|
97
|
+
this.metadata = props.metadata;
|
|
98
|
+
this.signature = props.signature;
|
|
99
|
+
Object.freeze(this);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static create(props: AttestationRecordProps): AttestationRecord {
|
|
103
|
+
// INV-1: gateResult == "pass" iff validatorSet.every(passed || skipped)
|
|
104
|
+
const allGreen = props.subject.validatorSet.every((o) => o.isGreen());
|
|
105
|
+
const expectedGateResult: GateResult = allGreen ? "pass" : "fail";
|
|
106
|
+
if (props.subject.gateResult !== expectedGateResult) {
|
|
107
|
+
throw new AttestationInvariantError(
|
|
108
|
+
`INV-1 violated: gateResult "${props.subject.gateResult}" does not match validatorSet allPassed rule (expected "${expectedGateResult}")`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// INV-3: granularity == GranularityDerivationService.derive(validatorSet)
|
|
113
|
+
const derived = derivationService.derive(props.subject.validatorSet);
|
|
114
|
+
if (!props.granularity.traceability.equals(derived)) {
|
|
115
|
+
throw new AttestationInvariantError(
|
|
116
|
+
"INV-3 violated: granularity.traceability does not match mechanically derived granularity (anti-laundering)",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// INV-6: unsigned-poc のとき algorithm/keyId/value は null(SignatureBlock.create で保証済み)
|
|
121
|
+
// INV-2: attestationDigest present iff mode set —
|
|
122
|
+
// SignatureBlock は常に mode + attestationDigest を持つため構造的に成立
|
|
123
|
+
if (props.signature.mode === "unsigned-poc") {
|
|
124
|
+
if (props.signature.algorithm !== null || props.signature.keyId !== null || props.signature.value !== null) {
|
|
125
|
+
throw new AttestationInvariantError(
|
|
126
|
+
"INV-6 violated: unsigned-poc signature must have null algorithm/keyId/value",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return new AttestationRecord(props);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 既存 document を「格納されたまま」に再構築する(verify 用)。
|
|
136
|
+
* INV-1/INV-3 を強制しない — verify はこれらを機械的チェックとして検出し
|
|
137
|
+
* check 失敗(exitCode 1)として報告する責務を持つため、ここで例外を投げてはならない。
|
|
138
|
+
* SignatureBlock 側の INV-6 は VO 構築時に既に保証されている。
|
|
139
|
+
*/
|
|
140
|
+
static reconstruct(props: AttestationRecordProps): AttestationRecord {
|
|
141
|
+
return new AttestationRecord(props);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
gateResult(): GateResult {
|
|
145
|
+
return this.subject.gateResult;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* §1.4.2: sources を path 昇順で安定ソートした `[{ path, digest }]` の canonical JSON の sha256。
|
|
150
|
+
*/
|
|
151
|
+
computeInputDigest(hasher: ContentHasherPort): Digest {
|
|
152
|
+
const sorted = [...this.inputs.sources]
|
|
153
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
154
|
+
.map((s) => ({ path: s.path, digest: s.digest.value }));
|
|
155
|
+
return hasher.sha256(canonicalStringify(sorted));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* §1.4.1 step1-3: signature ブロック全体と volatile metadata(producedAt/gitCommit)を
|
|
160
|
+
* 除去した plain object を返す(producer は決定論的なので残す)。
|
|
161
|
+
*/
|
|
162
|
+
toCanonicalPayload(): Record<string, unknown> {
|
|
163
|
+
return {
|
|
164
|
+
schemaVersion: this.schemaVersion,
|
|
165
|
+
predicateType: this.predicateType,
|
|
166
|
+
subject: {
|
|
167
|
+
command: this.subject.command,
|
|
168
|
+
gateResult: this.subject.gateResult,
|
|
169
|
+
validatorSet: this.subject.validatorSet.map((o) => ({
|
|
170
|
+
validatorId: o.validatorId,
|
|
171
|
+
passed: o.passed,
|
|
172
|
+
skipped: o.skipped,
|
|
173
|
+
})),
|
|
174
|
+
},
|
|
175
|
+
inputs: {
|
|
176
|
+
digestAlgorithm: this.inputs.digestAlgorithm,
|
|
177
|
+
sources: this.inputs.sources.map((s) => ({ path: s.path, digest: s.digest.value })),
|
|
178
|
+
inputDigest: this.inputs.inputDigest.value,
|
|
179
|
+
},
|
|
180
|
+
granularity: {
|
|
181
|
+
traceability: {
|
|
182
|
+
validator: this.granularity.traceability.validator,
|
|
183
|
+
level: this.granularity.traceability.level,
|
|
184
|
+
claim: this.granularity.traceability.claim,
|
|
185
|
+
knownLimitations: [...this.granularity.traceability.knownLimitations],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
metadata: {
|
|
189
|
+
producer: this.metadata.producer,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* §1.4.1 step4: canonical payload の canonical JSON を sha256 にかけて attestationDigest を得る。
|
|
196
|
+
*/
|
|
197
|
+
computeAttestationDigest(hasher: ContentHasherPort): Digest {
|
|
198
|
+
return hasher.sha256(canonicalStringify(this.toCanonicalPayload()));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* computeAttestationDigest() を signature.attestationDigest に反映した封印済み record を返す(INV-4)。
|
|
203
|
+
*/
|
|
204
|
+
seal(hasher: ContentHasherPort): AttestationRecord {
|
|
205
|
+
const digest = this.computeAttestationDigest(hasher);
|
|
206
|
+
return AttestationRecord.create({
|
|
207
|
+
schemaVersion: this.schemaVersion,
|
|
208
|
+
predicateType: this.predicateType,
|
|
209
|
+
subject: this.subject,
|
|
210
|
+
inputs: this.inputs,
|
|
211
|
+
granularity: this.granularity,
|
|
212
|
+
metadata: this.metadata,
|
|
213
|
+
signature: SignatureBlock.unsignedPoc(digest),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
equals(other: AttestationRecord): boolean {
|
|
218
|
+
if (this.schemaVersion !== other.schemaVersion) return false;
|
|
219
|
+
if (this.predicateType !== other.predicateType) return false;
|
|
220
|
+
if (this.subject.command !== other.subject.command) return false;
|
|
221
|
+
if (this.subject.gateResult !== other.subject.gateResult) return false;
|
|
222
|
+
if (this.subject.validatorSet.length !== other.subject.validatorSet.length) return false;
|
|
223
|
+
if (!this.subject.validatorSet.every((o, i) => o.equals(other.subject.validatorSet[i]))) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
if (this.inputs.digestAlgorithm !== other.inputs.digestAlgorithm) return false;
|
|
227
|
+
if (!this.inputs.inputDigest.equals(other.inputs.inputDigest)) return false;
|
|
228
|
+
if (this.inputs.sources.length !== other.inputs.sources.length) return false;
|
|
229
|
+
if (
|
|
230
|
+
!this.inputs.sources.every(
|
|
231
|
+
(s, i) => s.path === other.inputs.sources[i].path && s.digest.equals(other.inputs.sources[i].digest),
|
|
232
|
+
)
|
|
233
|
+
) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
if (!this.granularity.traceability.equals(other.granularity.traceability)) return false;
|
|
237
|
+
if (!this.signature.equals(other.signature)) return false;
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @unit attestation
|
|
2
|
+
// @layer domain
|
|
3
|
+
|
|
4
|
+
import type { Digest } from "../value-objects/digest.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* canonical payload / source content の sha256 を計算するポート(外部→ドメイン)。
|
|
8
|
+
* 実体(node:crypto)は Infrastructure に隔離する。
|
|
9
|
+
* digest 計算は集約の不変条件 INV-4/INV-5 の一部であるため domain 層に配置する。
|
|
10
|
+
*/
|
|
11
|
+
export interface ContentHasherPort {
|
|
12
|
+
sha256(content: string): Digest;
|
|
13
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @unit attestation
|
|
2
|
+
// @layer domain
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
GranularityClaim,
|
|
6
|
+
KNOWN_LIMITATIONS_REGISTRY,
|
|
7
|
+
TRACEABILITY_VALIDATOR_ID,
|
|
8
|
+
} from "../value-objects/granularity-claim.js";
|
|
9
|
+
import type { ValidatorOutcome } from "../value-objects/validator-outcome.js";
|
|
10
|
+
|
|
11
|
+
const TRACEABILITY_NOT_RUN_CLAIM =
|
|
12
|
+
"Traceability (L3-004) was not part of this gate run; no file-level traceability guarantee is asserted.";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* validator set + 静的 known-limitations registry から GranularityClaim を機械導出する純粋サービス。
|
|
16
|
+
* 生成(H16-01)と検証(H16-02 anti-laundering 再導出)で完全に同一の結果を返すため、
|
|
17
|
+
* 決定論的かつ全域(total)でなければならない。
|
|
18
|
+
*
|
|
19
|
+
* anti-laundering: 導出は静的 registry のみを真実の源とする。格納された granularity を
|
|
20
|
+
* 過大主張へ改竄しても、再導出は常に registry の honest な file-level 主張を返すため mismatch が検出される。
|
|
21
|
+
* また validator set から L3-004 を除去する改竄も、導出結果が「not run」へ変化するため検出される。
|
|
22
|
+
*/
|
|
23
|
+
export class GranularityDerivationService {
|
|
24
|
+
/**
|
|
25
|
+
* validator set から traceability の GranularityClaim を導出する。
|
|
26
|
+
* 1. registry から traceability 検査に対応する validatorId(L3-004)を引く
|
|
27
|
+
* 2. L3-004 が validator set に含まれるか確認する
|
|
28
|
+
* 3. 含まれる場合は registry の file-level 定義(known-limitation 付き)を、
|
|
29
|
+
* 含まれない場合は「not run」主張を構築する
|
|
30
|
+
*/
|
|
31
|
+
derive(validatorSet: readonly ValidatorOutcome[]): GranularityClaim {
|
|
32
|
+
const definition = KNOWN_LIMITATIONS_REGISTRY[TRACEABILITY_VALIDATOR_ID];
|
|
33
|
+
const present = validatorSet.some((o) => o.validatorId === TRACEABILITY_VALIDATOR_ID);
|
|
34
|
+
|
|
35
|
+
if (!present) {
|
|
36
|
+
return GranularityClaim.create({
|
|
37
|
+
validator: definition.validator,
|
|
38
|
+
level: definition.level,
|
|
39
|
+
claim: TRACEABILITY_NOT_RUN_CLAIM,
|
|
40
|
+
knownLimitations: [],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return GranularityClaim.create({
|
|
45
|
+
validator: definition.validator,
|
|
46
|
+
level: definition.level,
|
|
47
|
+
claim: definition.claim,
|
|
48
|
+
knownLimitations: definition.knownLimitations,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|