fix(mosaic): make credential lifecycle transactional

This commit is contained in:
2026-08-05 13:11:04 -05:00
committed by Hermes Agent
parent 050ac63737
commit f705800353
13 changed files with 389 additions and 43 deletions
+1 -1
View File
@@ -451,7 +451,7 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist
1. `CRED-REQ-01`: The CLI SHALL expose `provision`, `wire`, `grant`, `get`, `validate`, `whoami`, `list`, `rotate`, `revoke`, and `audit`. Grant and validate SHALL conform to [`docs/credentials/GRANT-VALIDATE-CONTRACT.md`](./credentials/GRANT-VALIDATE-CONTRACT.md). 1. `CRED-REQ-01`: The CLI SHALL expose `provision`, `wire`, `grant`, `get`, `validate`, `whoami`, `list`, `rotate`, `revoke`, and `audit`. Grant and validate SHALL conform to [`docs/credentials/GRANT-VALIDATE-CONTRACT.md`](./credentials/GRANT-VALIDATE-CONTRACT.md).
2. `CRED-REQ-02`: Every provider operation SHALL carry an explicit identity, estate, and host. Estate-to-host mapping SHALL come from strict non-secret configuration. Missing, ambiguous, inferred, or mismatched values SHALL refuse before credential resolution. Machine location SHALL grant no estate authority. 2. `CRED-REQ-02`: Every provider operation SHALL carry an explicit identity, estate, and host. Estate-to-host mapping SHALL come from strict non-secret configuration. Missing, ambiguous, inferred, or mismatched values SHALL refuse before credential resolution. Machine location SHALL grant no estate authority.
3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither, and SHALL read the provider `/user` object back through each path. A wrong-host or absent Tea login SHALL never fall back to a host default. 3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither. At mint time, delegated Basic authority SHALL read its provider principal back, the minted token object SHALL read back exact scopes, and both the token binding and exact host-bound Tea record SHALL contain that same minted credential. Runtime `/user` identity remeasurement is required only when the seat token already carries `read:user`; least-privilege tokens SHALL NOT be widened to service the instrument. A wrong-host or absent Tea login SHALL never fall back to a host default.
4. `CRED-REQ-04`: Under fleet context, unset or unresolvable identity SHALL fail closed identically in the git credential helper and API resolver. Interactive shared credentials remain available only through an explicit non-fleet/shared selection; absence SHALL never select them. 4. `CRED-REQ-04`: Under fleet context, unset or unresolvable identity SHALL fail closed identically in the git credential helper and API resolver. Interactive shared credentials remain available only through an explicit non-fleet/shared selection; absence SHALL never select them.
5. `CRED-REQ-05`: Token scope, repository permission, and organization/team role are independent layers. Provision, grant, and validate SHALL report each separately from provider evidence. No layer substitutes for another, and a permission widening at one layer SHALL not be described as least privilege because another layer is narrow. 5. `CRED-REQ-05`: Token scope, repository permission, and organization/team role are independent layers. Provision, grant, and validate SHALL report each separately from provider evidence. No layer substitutes for another, and a permission widening at one layer SHALL not be described as least privilege because another layer is narrow.
6. `CRED-REQ-06`: Gitea token creation SHALL use an explicit delegated provisioning step because this provider requires Basic Auth. Password-equivalent provisioning material SHALL enter only through a protected control-plane runtime credential channel, never caller bearer storage, argv, ordinary environment, logs, or output. 6. `CRED-REQ-06`: Gitea token creation SHALL use an explicit delegated provisioning step because this provider requires Basic Auth. Password-equivalent provisioning material SHALL enter only through a protected control-plane runtime credential channel, never caller bearer storage, argv, ordinary environment, logs, or output.
@@ -516,6 +516,11 @@ get_gitea_token() {
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)" _ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
_ident_src="git config mosaic.gitIdentity" _ident_src="git config mosaic.gitIdentity"
fi fi
if [[ -n "${MOSAIC_AGENT_NAME:-}" && -n "$_ident" && "$_ident" != "$MOSAIC_AGENT_NAME" ]]; then
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=%s\n' \
"$_ident" "$MOSAIC_AGENT_NAME" "$host" "$_ident_src" >&2
return 1
fi
if [[ -n "$_ident" ]]; then if [[ -n "$_ident" ]]; then
local _idpfx="" local _idpfx=""
case "$host" in case "$host" in
@@ -32,6 +32,12 @@ done
ident="$MOSAIC_GIT_IDENTITY" ident="$MOSAIC_GIT_IDENTITY"
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null) [ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
[ -z "$ident" ] && ident="$username_in" [ -z "$ident" ] && ident="$username_in"
if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then
echo "quit=true"
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
"$ident" "$MOSAIC_AGENT_NAME" "$host" >&2
exit 1
fi
if [ -n "$ident" ]; then if [ -n "$ident" ]; then
case "$host" in case "$host" in
git.uscllc.com) idpfx=gitea-usc;; git.uscllc.com) idpfx=gitea-usc;;
@@ -103,6 +103,15 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to
out=$(run_helper "git.mosaicstack.dev" "agentA") out=$(run_helper "git.mosaicstack.dev" "agentA")
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')" assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')" assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
set +e
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-mismatch.stderr")
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet identity override was not refused before token resolution" >&2
fail=1
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username. # 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
@@ -131,7 +140,7 @@ assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^u
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
set +e set +e
out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-missing.stderr") out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=no-such-agent 2>"$WORK_DIR/fleet-missing.stderr")
rc=$? rc=$?
set -e set -e
err=$(cat "$WORK_DIR/fleet-missing.stderr") err=$(cat "$WORK_DIR/fleet-missing.stderr")
@@ -111,6 +111,15 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to
git -C "$REPO_DIR" config mosaic.gitIdentity agentA git -C "$REPO_DIR" config mosaic.gitIdentity agentA
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA) out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA)
assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out" assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out"
set +e
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA MOSAIC_GIT_IDENTITY=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-mismatch.stderr")
if [[ "$rc" -eq 0 || -n "$out" || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet identity override was not refused before token resolution" >&2
fail=1
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity. # 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
+42 -6
View File
@@ -1,5 +1,5 @@
import { fstatSync, writeSync } from 'node:fs'; import { fstatSync, writeSync } from 'node:fs';
import { open, readFile, rename } from 'node:fs/promises'; import { open, rename } from 'node:fs/promises';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import type { Command } from 'commander'; import type { Command } from 'commander';
@@ -486,7 +486,7 @@ export async function executeCredentialRotate(
}); });
} }
const context = await lifecycleContext(options); const context = await lifecycleContext(options);
const old = await context.store.readBinding(identity, options.estate, options.host); const old = await context.store.snapshot(identity, options.estate, options.host);
if (old === undefined) { if (old === undefined) {
return localLifecycleResult('rotate', identity, options, { return localLifecycleResult('rotate', identity, options, {
outcome: 'refused', outcome: 'refused',
@@ -510,16 +510,17 @@ export async function executeCredentialRotate(
estate: options.estate, estate: options.estate,
host: options.host, host: options.host,
tokenName: options.tokenName, tokenName: options.tokenName,
scopes: (options.scopes ?? old.scopes.join(',')).split(',').filter(Boolean), scopes: (options.scopes ?? old.binding.scopes.join(',')).split(',').filter(Boolean),
}, },
authority, authority,
context.provider, context.provider,
context.store, context.store,
context.teaStore, context.teaStore,
{ stateRoot: context.stateRoot, actor: options.actor }, { stateRoot: context.stateRoot, actor: options.actor, allowReplace: true },
); );
if (provisioned.outcome !== 'ok') { if (provisioned.outcome !== 'ok') {
await journal.seal(provisioned.outcome, provisioned.reason.code); await journal.seal(provisioned.outcome, provisioned.reason.code);
old.secret.fill(0);
return { return {
...provisioned, ...provisioned,
operation: 'rotate', operation: 'rotate',
@@ -527,9 +528,32 @@ export async function executeCredentialRotate(
}; };
} }
await journal.recordMutation('token-mint-applied'); await journal.recordMutation('token-mint-applied');
await context.provider.revokeToken(authority, identity, old.tokenName); try {
await context.provider.revokeToken(authority, identity, old.binding.tokenName);
if (await context.provider.tokenExists(authority, identity, old.binding.tokenName)) {
throw new Error('old token still exists');
}
await journal.recordMutation('token-revoke-applied'); await journal.recordMutation('token-revoke-applied');
} catch {
await context.provider.revokeToken(authority, identity, options.tokenName);
if (await context.provider.tokenExists(authority, identity, options.tokenName)) {
throw new Error('replacement rollback could not be verified');
}
await context.store.put(old.binding, old.secret);
await context.teaStore.put(identity, options.host, old.secret);
await journal.recordMutation('rotate-rollback-verified');
await journal.seal('indeterminate', 'old-credential-preserved');
old.secret.fill(0);
return localLifecycleResult('rotate', identity, options, {
outcome: 'indeterminate',
mutation: 'none',
code: 'old-credential-preserved',
message: 'Replacement failed; the previous credential remains the canonical binding.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
}
await journal.seal('ok', 'rotate-verified'); await journal.seal('ok', 'rotate-verified');
old.secret.fill(0);
return { return {
...provisioned, ...provisioned,
operation: 'rotate', operation: 'rotate',
@@ -571,7 +595,19 @@ export async function executeCredentialWire(
}); });
await journal.recordIntent('wire-requested'); await journal.recordIntent('wire-requested');
try { try {
const existing = await readFile(options.seatEnv, 'utf8').catch((): string => ''); let existing = '';
try {
const snapshot = readRegularFileSecure(options.seatEnv, {
root: dirname(options.seatEnv),
maxBytes: 1024 * 1024,
});
if ((snapshot.mode & 0o022) !== 0 || snapshot.uid !== process.getuid?.()) {
throw new Error('seat environment ownership or mode is unsafe');
}
existing = snapshot.content.toString('utf8');
} catch (error: unknown) {
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
}
const lines = existing const lines = existing
.split(/\r?\n/) .split(/\r?\n/)
.filter( .filter(
@@ -48,6 +48,8 @@ const SAFE_DECISIONS = new Set<string>([
'token-mint-applied', 'token-mint-applied',
'token-binding-stored', 'token-binding-stored',
'tea-login-stored', 'tea-login-stored',
'provision-rollback-verified',
'rotate-rollback-verified',
'token-revoke-applied', 'token-revoke-applied',
'wire-applied', 'wire-applied',
'credential-issued', 'credential-issued',
@@ -7,4 +7,5 @@ export interface CredentialBindingMetadataDto {
readonly tokenName: string; readonly tokenName: string;
readonly scopes: readonly string[]; readonly scopes: readonly string[];
readonly createdAt: string; readonly createdAt: string;
readonly tokenDigest?: string;
} }
@@ -1,4 +1,4 @@
import { randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { lstatSync } from 'node:fs'; import { lstatSync } from 'node:fs';
import { open, readdir, rename, unlink } from 'node:fs/promises'; import { open, readdir, rename, unlink } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -24,6 +24,10 @@ const bindingSchema = z
tokenName: z.string().regex(IDENTITY), tokenName: z.string().regex(IDENTITY),
scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32), scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
tokenDigest: z
.string()
.regex(/^[a-f0-9]{64}$/)
.optional(),
}) })
.strict(); .strict();
@@ -110,6 +114,28 @@ export class FileCredentialResolver implements CredentialResolver {
throw error; throw error;
} }
const bindingPath = join(
this.tokenDirectory,
`${hostConfig.tokenPrefix}-${identity}.binding.json`,
);
try {
const bindingSnapshot = readRegularFileSecure(bindingPath, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8')));
const digest = createHash('sha256').update(snapshot.content).digest('hex');
if (!binding.success || binding.data.tokenDigest !== digest) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'token and binding metadata are not one committed generation',
);
}
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
// Legacy token files predate binding metadata and remain readable until rotated.
}
const permissions = snapshot.mode & 0o777; const permissions = snapshot.mode & 0o777;
if (snapshot.uid !== currentUid) { if (snapshot.uid !== currentUid) {
throw new CredentialStoreError( throw new CredentialStoreError(
@@ -180,10 +206,24 @@ export class FileCredentialStore {
const paths = this.paths(metadata.identity, metadata.estate, metadata.host); const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory); ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
const token = validateSecret(Buffer.from(secret)); const token = validateSecret(Buffer.from(secret));
const binding = bindingSchema.parse({ ...metadata, schemaVersion: 1 }); const binding = bindingSchema.parse({
...metadata,
schemaVersion: 1,
tokenDigest: createHash('sha256').update(token).digest('hex'),
});
const suffix = randomUUID(); const suffix = randomUUID();
const tokenTemp = `${paths.token}.${suffix}.tmp`; const tokenTemp = `${paths.token}.${suffix}.tmp`;
const bindingTemp = `${paths.binding}.${suffix}.tmp`; const bindingTemp = `${paths.binding}.${suffix}.tmp`;
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
let lock;
try {
lock = await open(lockPath, 'wx', 0o600);
} catch {
throw new CredentialStoreError(
'conflicting-credential-mutation',
'another mutation owns the identity lock',
);
}
const tokenHandle = await open(tokenTemp, 'wx', 0o600); const tokenHandle = await open(tokenTemp, 'wx', 0o600);
const bindingHandle = await open(bindingTemp, 'wx', 0o600); const bindingHandle = await open(bindingTemp, 'wx', 0o600);
try { try {
@@ -195,9 +235,41 @@ export class FileCredentialStore {
await tokenHandle.close(); await tokenHandle.close();
await bindingHandle.close(); await bindingHandle.close();
} }
try {
await rename(bindingTemp, paths.binding); await rename(bindingTemp, paths.binding);
await rename(tokenTemp, paths.token); await rename(tokenTemp, paths.token);
await syncDirectory(this.tokenDirectory); await syncDirectory(this.tokenDirectory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
await syncDirectory(this.tokenDirectory);
}
}
async snapshot(
identity: string,
estate: string,
host: string,
): Promise<
| {
readonly binding: CredentialBindingMetadataDto;
readonly secret: Uint8Array;
}
| undefined
> {
const binding = await this.readBinding(identity, estate, host);
if (binding === undefined) return undefined;
const resolved = await new FileCredentialResolver(
this.tokenDirectory,
this.estateRegistry,
).resolve(identity, estate, host);
if (resolved === undefined) {
throw new CredentialStoreError(
'invalid-binding',
'binding metadata exists without its token generation',
);
}
return { binding, secret: new Uint8Array(resolved.secret) };
} }
async readBinding( async readBinding(
@@ -812,6 +812,39 @@ export class GiteaLifecycleProviderAdapter
}; };
} }
async tokenExists(
authority: ResolvedCredential,
identity: string,
name: string,
): Promise<boolean> {
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
{
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
'readback-missing',
'token absence read-back failed',
);
}
const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response));
if (!parsed.success) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'token absence read-back was malformed',
);
}
return parsed.data.some((token): boolean => token.name === name);
}
async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void> { async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void> {
const response = await this.request( const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`, `${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`,
@@ -81,6 +81,9 @@ function provider(): GiteaLifecycleProvider {
}; };
}, },
async revokeToken(): Promise<void> {}, async revokeToken(): Promise<void> {},
async tokenExists(): Promise<boolean> {
return false;
},
}; };
} }
@@ -108,6 +111,42 @@ describe('credential lifecycle', (): void => {
expect(JSON.stringify(result)).not.toContain('minted-token-canary'); expect(JSON.stringify(result)).not.toContain('minted-token-canary');
}); });
it('rolls back a minted token when exact scope read-back disagrees', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
let revoked = false;
const lifecycleProvider = provider();
lifecycleProvider.readToken = async (_authority, _identity, name) => ({
name,
scopes: ['admin'],
endpoint: 'GET /api/v1/users/seat/tokens',
contentType: 'application/json',
});
lifecycleProvider.revokeToken = async (): Promise<void> => {
revoked = true;
};
lifecycleProvider.tokenExists = async (): Promise<boolean> => false;
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-bad',
scopes: ['write:repository'],
},
authority,
lifecycleProvider,
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result.outcome).toBe('error');
expect(result.mutation).toBe('none');
expect(revoked).toBe(true);
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('revokes at provider before removing the local binding', async (): Promise<void> => { it('revokes at provider before removing the local binding', async (): Promise<void> => {
const { root, store, teaStore } = await fixture(); const { root, store, teaStore } = await fixture();
await provisionCredential( await provisionCredential(
@@ -140,4 +179,34 @@ describe('credential lifecycle', (): void => {
expect(revoked).toBe(true); expect(revoked).toBe(true);
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]); await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
}); });
it('preserves the local recovery binding when provider revocation read-back still finds the token', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-1',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
const lifecycleProvider = provider();
lifecycleProvider.tokenExists = async (): Promise<boolean> => true;
const result = await revokeCredential(
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
authority,
lifecycleProvider,
store,
{ stateRoot: root, actor: 'seat' },
);
expect(result.outcome).toBe('indeterminate');
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']);
});
}); });
+71 -26
View File
@@ -31,6 +31,7 @@ export interface GiteaLifecycleProvider {
name: string, name: string,
): Promise<TokenObjectEvidenceDto>; ): Promise<TokenObjectEvidenceDto>;
revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void>; revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void>;
tokenExists(authority: ResolvedCredential, identity: string, name: string): Promise<boolean>;
} }
export interface LifecycleRequest { export interface LifecycleRequest {
@@ -48,6 +49,7 @@ export interface LifecycleOptions {
readonly stateRoot: string; readonly stateRoot: string;
readonly actor: string; readonly actor: string;
readonly now?: () => string; readonly now?: () => string;
readonly allowReplace?: boolean;
} }
function lifecycleResult( function lifecycleResult(
@@ -114,6 +116,19 @@ export async function provisionCredential(
const journal = await openLifecycleJournal('provision', request, options); const journal = await openLifecycleJournal('provision', request, options);
let mutation: 'none' | 'unknown' | 'applied' = 'none'; let mutation: 'none' | 'unknown' | 'applied' = 'none';
let minted: MintedToken | undefined; let minted: MintedToken | undefined;
let failureCode = 'mutation-state-unknown';
const prior = await store.snapshot(request.identity, request.estate, request.host);
if (prior !== undefined && options.allowReplace !== true) {
await journal.seal('refused', 'credential-already-exists');
return lifecycleResult('provision', request, {
outcome: 'refused',
mutation: 'none',
code: 'credential-already-exists',
message: 'A governed credential already exists; use rotate.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
try { try {
const identity = await provider.readBasicIdentity(authority); const identity = await provider.readBasicIdentity(authority);
if (identity.login !== request.identity || authority.identity !== request.identity) { if (identity.login !== request.identity || authority.identity !== request.identity) {
@@ -146,17 +161,8 @@ export async function provisionCredential(
const expected = [...request.scopes].sort(); const expected = [...request.scopes].sort();
const actual = [...readBack.scopes].sort(); const actual = [...readBack.scopes].sort();
if (JSON.stringify(expected) !== JSON.stringify(actual)) { if (JSON.stringify(expected) !== JSON.stringify(actual)) {
await journal.seal('indeterminate', 'scope-not-evaluable'); failureCode = 'scope-not-evaluable';
return lifecycleResult('provision', request, { throw new Error('scope read-back disagreed');
outcome: 'indeterminate',
mutation,
code: 'scope-not-evaluable',
message: 'Minted token scope read-back disagreed with the request.',
journalId: journal.journalId(),
auditState: 'sealed',
providerIdentity: identity.login,
token: readBack,
});
} }
await store.put( await store.put(
{ {
@@ -173,18 +179,12 @@ export async function provisionCredential(
await journal.recordMutation('token-binding-stored'); await journal.recordMutation('token-binding-stored');
await teaStore.put(request.identity, request.host, minted.secret); await teaStore.put(request.identity, request.host, minted.secret);
const teaLogin = teaStore.readBack(request.identity, request.host); const teaLogin = teaStore.readBack(request.identity, request.host);
if (teaLogin === undefined) { if (
await journal.seal('indeterminate', 'tea-login-missing'); teaLogin === undefined ||
return lifecycleResult('provision', request, { !teaStore.matchesSecret(request.identity, request.host, minted.secret)
outcome: 'indeterminate', ) {
mutation: 'applied', failureCode = 'tea-login-missing';
code: 'tea-login-missing', throw new Error('Tea login did not resolve exactly');
message: 'Token was stored but the host-bound Tea login did not read back.',
journalId: journal.journalId(),
auditState: 'sealed',
providerIdentity: identity.login,
token: readBack,
});
} }
await journal.recordMutation('tea-login-stored'); await journal.recordMutation('tea-login-stored');
await journal.recordProviderEvidence({ await journal.recordProviderEvidence({
@@ -206,18 +206,52 @@ export async function provisionCredential(
}); });
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof CredentialJournalError) throw error; if (error instanceof CredentialJournalError) throw error;
const code = mutation === 'none' ? 'provider-unavailable' : 'mutation-state-unknown'; if (minted === undefined) {
await journal.seal('indeterminate', code); await journal.seal('indeterminate', 'provider-unavailable');
return lifecycleResult('provision', request, { return lifecycleResult('provision', request, {
outcome: 'indeterminate', outcome: 'indeterminate',
mutation, mutation,
code: 'provider-unavailable',
message: 'Provider token mint did not complete.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
let rollbackComplete = false;
try {
await provider.revokeToken(authority, request.identity, request.tokenName);
if (await provider.tokenExists(authority, request.identity, request.tokenName)) {
throw new Error('minted token still exists after rollback');
}
if (prior === undefined) {
await store.remove(request.identity, request.estate, request.host);
await teaStore.remove(request.identity, request.host).catch((): void => undefined);
} else {
await store.put(prior.binding, prior.secret);
await teaStore.put(request.identity, request.host, prior.secret);
}
rollbackComplete = true;
await journal.recordMutation('provision-rollback-verified');
} catch {
rollbackComplete = false;
} finally {
prior?.secret.fill(0);
}
const code = rollbackComplete ? failureCode : 'rollback-incomplete';
await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code);
return lifecycleResult('provision', request, {
outcome: rollbackComplete ? 'error' : 'indeterminate',
mutation: rollbackComplete ? 'none' : mutation,
code, code,
message: 'Provisioning did not produce complete provider and storage evidence.', message: rollbackComplete
? 'Provisioning failed and every completed mutation was rolled back.'
: 'Provisioning failed and rollback could not be proven complete.',
journalId: journal.journalId(), journalId: journal.journalId(),
auditState: 'sealed', auditState: 'sealed',
}); });
} finally { } finally {
minted?.secret.fill(0); minted?.secret.fill(0);
prior?.secret.fill(0);
} }
} }
@@ -247,6 +281,17 @@ export async function revokeCredential(
await provider.revokeToken(authority, request.identity, binding.tokenName); await provider.revokeToken(authority, request.identity, binding.tokenName);
mutation = 'applied'; mutation = 'applied';
await journal.recordMutation('token-revoke-applied'); await journal.recordMutation('token-revoke-applied');
if (await provider.tokenExists(authority, request.identity, binding.tokenName)) {
await journal.seal('indeterminate', 'revoke-readback-missing');
return lifecycleResult('revoke', request, {
outcome: 'indeterminate',
mutation,
code: 'revoke-readback-missing',
message: 'Provider still returned the token after revocation acknowledgement.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
await store.remove(request.identity, request.estate, request.host); await store.remove(request.identity, request.estate, request.host);
await journal.seal('ok', 'revoke-verified'); await journal.seal('ok', 'revoke-verified');
return lifecycleResult('revoke', request, { return lifecycleResult('revoke', request, {
@@ -1,9 +1,10 @@
import { randomUUID } from 'node:crypto'; import { randomUUID, timingSafeEqual } from 'node:crypto';
import { open, rename } from 'node:fs/promises'; import { open, rename } from 'node:fs/promises';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { parse, stringify } from 'yaml'; import { parse, stringify } from 'yaml';
import { z } from 'zod'; import { z } from 'zod';
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js'; import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
@@ -91,6 +92,64 @@ export class TeaLoginStore {
await rename(temp, this.configPath); await rename(temp, this.configPath);
} }
resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined {
const directory = dirname(this.configPath);
let snapshot;
try {
snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
} catch (error: unknown) {
if (missing(error)) return undefined;
throw error;
}
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) return undefined;
const matches = decoded.data.logins.filter(
(login): boolean =>
login.name === identity && login.url === `https://${host}` && login.user === identity,
);
if (matches.length !== 1 || matches[0] === undefined) return undefined;
return Object.freeze({
identity,
estate,
host,
resolutionId: randomUUID(),
secret: new TextEncoder().encode(matches[0].token),
});
}
matchesSecret(identity: string, host: string, secret: Uint8Array): boolean {
const resolved = this.resolve(identity, 'binding-check', host);
if (resolved === undefined || resolved.secret.byteLength !== secret.byteLength) return false;
return timingSafeEqual(Buffer.from(resolved.secret), Buffer.from(secret));
}
async remove(identity: string, host: string): Promise<void> {
const directory = dirname(this.configPath);
const snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
const logins = decoded.data.logins.filter(
(login): boolean => !(login.name === identity && login.url === `https://${host}`),
);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify({ logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, this.configPath);
}
readBack( readBack(
identity: string, identity: string,
host: string, host: string,