fix(mosaic): make credential lifecycle transactional
This commit is contained in:
@@ -516,6 +516,11 @@ get_gitea_token() {
|
||||
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||
_ident_src="git config mosaic.gitIdentity"
|
||||
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
|
||||
local _idpfx=""
|
||||
case "$host" in
|
||||
|
||||
@@ -32,6 +32,12 @@ done
|
||||
ident="$MOSAIC_GIT_IDENTITY"
|
||||
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||
[ -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
|
||||
case "$host" in
|
||||
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")
|
||||
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=')"
|
||||
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.
|
||||
@@ -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=')"
|
||||
|
||||
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=$?
|
||||
set -e
|
||||
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
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA)
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { dirname, join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
@@ -486,7 +486,7 @@ export async function executeCredentialRotate(
|
||||
});
|
||||
}
|
||||
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) {
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
outcome: 'refused',
|
||||
@@ -510,16 +510,17 @@ export async function executeCredentialRotate(
|
||||
estate: options.estate,
|
||||
host: options.host,
|
||||
tokenName: options.tokenName,
|
||||
scopes: (options.scopes ?? old.scopes.join(',')).split(',').filter(Boolean),
|
||||
scopes: (options.scopes ?? old.binding.scopes.join(',')).split(',').filter(Boolean),
|
||||
},
|
||||
authority,
|
||||
context.provider,
|
||||
context.store,
|
||||
context.teaStore,
|
||||
{ stateRoot: context.stateRoot, actor: options.actor },
|
||||
{ stateRoot: context.stateRoot, actor: options.actor, allowReplace: true },
|
||||
);
|
||||
if (provisioned.outcome !== 'ok') {
|
||||
await journal.seal(provisioned.outcome, provisioned.reason.code);
|
||||
old.secret.fill(0);
|
||||
return {
|
||||
...provisioned,
|
||||
operation: 'rotate',
|
||||
@@ -527,9 +528,32 @@ export async function executeCredentialRotate(
|
||||
};
|
||||
}
|
||||
await journal.recordMutation('token-mint-applied');
|
||||
await context.provider.revokeToken(authority, identity, old.tokenName);
|
||||
await journal.recordMutation('token-revoke-applied');
|
||||
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');
|
||||
} 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');
|
||||
old.secret.fill(0);
|
||||
return {
|
||||
...provisioned,
|
||||
operation: 'rotate',
|
||||
@@ -571,7 +595,19 @@ export async function executeCredentialWire(
|
||||
});
|
||||
await journal.recordIntent('wire-requested');
|
||||
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
|
||||
.split(/\r?\n/)
|
||||
.filter(
|
||||
|
||||
@@ -48,6 +48,8 @@ const SAFE_DECISIONS = new Set<string>([
|
||||
'token-mint-applied',
|
||||
'token-binding-stored',
|
||||
'tea-login-stored',
|
||||
'provision-rollback-verified',
|
||||
'rotate-rollback-verified',
|
||||
'token-revoke-applied',
|
||||
'wire-applied',
|
||||
'credential-issued',
|
||||
|
||||
@@ -7,4 +7,5 @@ export interface CredentialBindingMetadataDto {
|
||||
readonly tokenName: string;
|
||||
readonly scopes: readonly 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 { open, readdir, rename, unlink } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
@@ -24,6 +24,10 @@ const bindingSchema = z
|
||||
tokenName: z.string().regex(IDENTITY),
|
||||
scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32),
|
||||
createdAt: z.string().datetime(),
|
||||
tokenDigest: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -110,6 +114,28 @@ export class FileCredentialResolver implements CredentialResolver {
|
||||
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;
|
||||
if (snapshot.uid !== currentUid) {
|
||||
throw new CredentialStoreError(
|
||||
@@ -180,10 +206,24 @@ export class FileCredentialStore {
|
||||
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
|
||||
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
|
||||
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 tokenTemp = `${paths.token}.${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 bindingHandle = await open(bindingTemp, 'wx', 0o600);
|
||||
try {
|
||||
@@ -195,9 +235,41 @@ export class FileCredentialStore {
|
||||
await tokenHandle.close();
|
||||
await bindingHandle.close();
|
||||
}
|
||||
await rename(bindingTemp, paths.binding);
|
||||
await rename(tokenTemp, paths.token);
|
||||
await syncDirectory(this.tokenDirectory);
|
||||
try {
|
||||
await rename(bindingTemp, paths.binding);
|
||||
await rename(tokenTemp, paths.token);
|
||||
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(
|
||||
|
||||
@@ -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> {
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`,
|
||||
|
||||
@@ -81,6 +81,9 @@ function provider(): GiteaLifecycleProvider {
|
||||
};
|
||||
},
|
||||
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');
|
||||
});
|
||||
|
||||
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> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
await provisionCredential(
|
||||
@@ -140,4 +179,34 @@ describe('credential lifecycle', (): void => {
|
||||
expect(revoked).toBe(true);
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface GiteaLifecycleProvider {
|
||||
name: string,
|
||||
): Promise<TokenObjectEvidenceDto>;
|
||||
revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void>;
|
||||
tokenExists(authority: ResolvedCredential, identity: string, name: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface LifecycleRequest {
|
||||
@@ -48,6 +49,7 @@ export interface LifecycleOptions {
|
||||
readonly stateRoot: string;
|
||||
readonly actor: string;
|
||||
readonly now?: () => string;
|
||||
readonly allowReplace?: boolean;
|
||||
}
|
||||
|
||||
function lifecycleResult(
|
||||
@@ -114,6 +116,19 @@ export async function provisionCredential(
|
||||
const journal = await openLifecycleJournal('provision', request, options);
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
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 {
|
||||
const identity = await provider.readBasicIdentity(authority);
|
||||
if (identity.login !== request.identity || authority.identity !== request.identity) {
|
||||
@@ -146,17 +161,8 @@ export async function provisionCredential(
|
||||
const expected = [...request.scopes].sort();
|
||||
const actual = [...readBack.scopes].sort();
|
||||
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
|
||||
await journal.seal('indeterminate', 'scope-not-evaluable');
|
||||
return lifecycleResult('provision', request, {
|
||||
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,
|
||||
});
|
||||
failureCode = 'scope-not-evaluable';
|
||||
throw new Error('scope read-back disagreed');
|
||||
}
|
||||
await store.put(
|
||||
{
|
||||
@@ -173,18 +179,12 @@ export async function provisionCredential(
|
||||
await journal.recordMutation('token-binding-stored');
|
||||
await teaStore.put(request.identity, request.host, minted.secret);
|
||||
const teaLogin = teaStore.readBack(request.identity, request.host);
|
||||
if (teaLogin === undefined) {
|
||||
await journal.seal('indeterminate', 'tea-login-missing');
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'indeterminate',
|
||||
mutation: 'applied',
|
||||
code: 'tea-login-missing',
|
||||
message: 'Token was stored but the host-bound Tea login did not read back.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
providerIdentity: identity.login,
|
||||
token: readBack,
|
||||
});
|
||||
if (
|
||||
teaLogin === undefined ||
|
||||
!teaStore.matchesSecret(request.identity, request.host, minted.secret)
|
||||
) {
|
||||
failureCode = 'tea-login-missing';
|
||||
throw new Error('Tea login did not resolve exactly');
|
||||
}
|
||||
await journal.recordMutation('tea-login-stored');
|
||||
await journal.recordProviderEvidence({
|
||||
@@ -206,18 +206,52 @@ export async function provisionCredential(
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CredentialJournalError) throw error;
|
||||
const code = mutation === 'none' ? 'provider-unavailable' : 'mutation-state-unknown';
|
||||
await journal.seal('indeterminate', code);
|
||||
if (minted === undefined) {
|
||||
await journal.seal('indeterminate', 'provider-unavailable');
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'indeterminate',
|
||||
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: 'indeterminate',
|
||||
mutation,
|
||||
outcome: rollbackComplete ? 'error' : 'indeterminate',
|
||||
mutation: rollbackComplete ? 'none' : mutation,
|
||||
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(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
} finally {
|
||||
minted?.secret.fill(0);
|
||||
prior?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +281,17 @@ export async function revokeCredential(
|
||||
await provider.revokeToken(authority, request.identity, binding.tokenName);
|
||||
mutation = '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 journal.seal('ok', 'revoke-verified');
|
||||
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 { dirname } from 'node:path';
|
||||
import { parse, stringify } from 'yaml';
|
||||
import { z } from 'zod';
|
||||
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_.-]*$/;
|
||||
|
||||
@@ -91,6 +92,64 @@ export class TeaLoginStore {
|
||||
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(
|
||||
identity: string,
|
||||
host: string,
|
||||
|
||||
Reference in New Issue
Block a user